├── .deployment ├── src ├── react-app-env.d.ts ├── models │ ├── loadingState.ts │ ├── popoversData.ts │ ├── warningsData.ts │ ├── roomsData.ts │ ├── mapData.ts │ ├── locationsData.ts │ └── sidebar.ts ├── components │ ├── FavoritesSwitcher │ │ ├── NoFavorites │ │ │ ├── NoFavorites.scss │ │ │ └── NoFavorites.tsx │ │ └── FavoritesSwitcher.tsx │ ├── LayersSwitcher │ │ ├── LayersSwitcher.scss │ │ ├── Switch │ │ │ ├── Switch.scss │ │ │ └── Switch.tsx │ │ ├── LayerChildrenMenu │ │ │ ├── LayerChildrenMenu.scss │ │ │ └── LayerChildrenMenu.tsx │ │ └── LayersSwitcher.tsx │ ├── UserControl │ │ ├── index.scss │ │ └── index.tsx │ ├── MSLogo │ │ ├── MSLogo.scss │ │ └── MSLogo.tsx │ ├── StatsSidebar │ │ ├── PieChart │ │ │ ├── PieChart.scss │ │ │ ├── LegendItem.scss │ │ │ ├── LegendItem.tsx │ │ │ └── PieChart.tsx │ │ ├── StatsSidebar.scss │ │ ├── SliderChart │ │ │ ├── SliderChart.scss │ │ │ └── SliderChart.tsx │ │ ├── TextLabel │ │ │ ├── TextLabel.scss │ │ │ └── TextLabel.tsx │ │ ├── Alert │ │ │ ├── Alert.scss │ │ │ └── Alert.tsx │ │ ├── SliderControl │ │ │ ├── SliderControl.scss │ │ │ └── SliderControl.tsx │ │ ├── GroupHeader │ │ │ ├── GroupHeader.scss │ │ │ └── GroupHeader.tsx │ │ ├── BarChart │ │ │ └── BarChart.tsx │ │ ├── VerticalBarChart │ │ │ └── VerticalBarChart.tsx │ │ ├── LineChart │ │ │ └── LineChart.tsx │ │ └── StatsSidebarShimmer │ │ │ └── StatsSidebarShimmer.tsx │ ├── AppHeader │ │ ├── AppHeader.scss │ │ └── AppHeader.tsx │ ├── NoResults │ │ ├── NoResults.scss │ │ └── NoResults.tsx │ ├── FavoriteLocationButton │ │ ├── FavoriteLocationButton.scss │ │ └── FavoriteLocationButton.tsx │ ├── SideNavBar │ │ ├── NavbarButton │ │ │ ├── NavbarButton.scss │ │ │ └── NavbarButton.tsx │ │ ├── SideNavBar.scss │ │ └── SideNavBar.tsx │ ├── Map │ │ ├── Map.scss │ │ ├── WarningPopover │ │ │ └── WarningPopover.tsx │ │ └── Popover │ │ │ └── index.tsx │ ├── LocationBreadcrumb │ │ └── LocationBreadcrumb.scss │ ├── Legend │ │ ├── Legend.scss │ │ └── Legend.tsx │ ├── SearchCallout │ │ ├── SearchCallout.scss │ │ └── SearchCallout.tsx │ ├── LocationSwitcher │ │ └── LocationSwitcher.tsx │ └── RoomsNavigator │ │ └── RoomsNavigator.tsx ├── setupTests.ts ├── utils │ ├── statsSidebarUtils.ts │ ├── validationUtils.ts │ ├── colorUtils.ts │ └── warningsUtils.ts ├── App.scss ├── index.scss ├── css │ ├── colors.scss │ └── scrollbar.scss ├── reducers │ ├── map.ts │ ├── popover.ts │ ├── simulation.ts │ ├── user.ts │ ├── layersData.ts │ ├── sidebar.ts │ ├── rooms.ts │ ├── sensors.ts │ ├── warnings.ts │ └── indoor.ts ├── types │ └── cssAnimationSync.d.ts ├── index.tsx ├── store │ └── store.ts ├── services │ ├── layers │ │ ├── Layer.ts │ │ ├── Navigator.ts │ │ ├── FloorsLayer.ts │ │ └── MarkersLayer.ts │ └── favoritesService.ts ├── config.ts ├── App.tsx └── mockData │ └── buildingData.ts ├── images ├── app.png └── sim.png ├── public ├── logo.png ├── ps.png ├── favicon.ico ├── robots.txt ├── static │ └── images │ │ ├── microsoft-logo.png │ │ ├── weather │ │ ├── cloud.png │ │ ├── sun.big.png │ │ ├── cloud.fog.png │ │ ├── cloud.rain.png │ │ ├── cloud.snow.png │ │ ├── cloud.dark.fog.png │ │ ├── cloud.drizzle.png │ │ ├── sun.big.cloud.png │ │ ├── sun.rays.cloud.png │ │ ├── sun.rays.small.png │ │ ├── cloud.dark.rain.png │ │ ├── cloud.dark.snow.png │ │ ├── sun.big.cloud.drizzle.png │ │ ├── sun.rays.small.cloud.png │ │ └── cloud.dark.multiple.lightning.png │ │ └── empty-search.svg ├── manifest.json └── index.html ├── .vs └── livemaps-api │ └── v16 │ └── .suo ├── docs └── LiveMapsArchitecture.png ├── .vscode ├── extensions.json ├── settings.json ├── launch.json └── tasks.json ├── CHANGELOG.md ├── livemaps-api ├── host.json ├── Models │ ├── Level.cs │ ├── Building.cs │ ├── RoomDataItem.cs │ ├── SideBar │ │ ├── LabelItem.cs │ │ ├── NameValueDataItem.cs │ │ └── Group.cs │ ├── Atlas │ │ ├── Geometry.cs │ │ ├── Feature.cs │ │ ├── FeatureCollection.cs │ │ └── Properties.cs │ ├── Room.cs │ ├── RoomItem.cs │ ├── Warning.cs │ ├── TagObject.cs │ ├── Device.cs │ ├── RecentDataItem.cs │ ├── UnitInfo.cs │ ├── BuildingConfig.cs │ ├── Position.cs │ ├── FloorInfo.cs │ └── SiteMapItem.cs ├── Services │ ├── SiteMapBuilder.cs │ ├── BlobDataService.cs │ └── MapsService.cs ├── livemaps.api.csproj ├── Properties │ └── PublishProfiles │ │ └── ssirapi - Web Deploy.pubxml └── APIFunctions │ ├── AtlasUnits.cs │ ├── Warnings.cs │ ├── DeviceState.cs │ ├── SideBar.cs │ ├── SiteMap.cs │ └── Config.cs ├── server.js ├── .gitignore ├── .github ├── CODE_OF_CONDUCT.md ├── workflows │ ├── build.yml │ ├── deploy_production.yml │ └── deploy_staging.yml ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .env ├── tsconfig.json ├── web.config ├── LICENSE.md ├── livemaps-api.sln ├── package.json ├── deploy.sh └── CONTRIBUTING.md /.deployment: -------------------------------------------------------------------------------- 1 | [config] 2 | command = bash deploy.sh -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /images/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/images/app.png -------------------------------------------------------------------------------- /images/sim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/images/sim.png -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/ps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/ps.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.vs/livemaps-api/v16/.suo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/.vs/livemaps-api/v16/.suo -------------------------------------------------------------------------------- /docs/LiveMapsArchitecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/docs/LiveMapsArchitecture.png -------------------------------------------------------------------------------- /src/models/loadingState.ts: -------------------------------------------------------------------------------- 1 | export enum LoadingState { 2 | Loading = 'loading', 3 | Ready = 'ready', 4 | Error = 'error', 5 | } -------------------------------------------------------------------------------- /public/static/images/microsoft-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/microsoft-logo.png -------------------------------------------------------------------------------- /public/static/images/weather/cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/cloud.png -------------------------------------------------------------------------------- /public/static/images/weather/sun.big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/sun.big.png -------------------------------------------------------------------------------- /public/static/images/weather/cloud.fog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/cloud.fog.png -------------------------------------------------------------------------------- /src/components/FavoritesSwitcher/NoFavorites/NoFavorites.scss: -------------------------------------------------------------------------------- 1 | .no-favorites { 2 | padding: 20px 30px; 3 | background-color: white; 4 | } -------------------------------------------------------------------------------- /public/static/images/weather/cloud.rain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/cloud.rain.png -------------------------------------------------------------------------------- /public/static/images/weather/cloud.snow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/cloud.snow.png -------------------------------------------------------------------------------- /src/components/LayersSwitcher/LayersSwitcher.scss: -------------------------------------------------------------------------------- 1 | .layers-switcher { 2 | padding: 16px 0; 3 | width: 300px; 4 | max-height: 350px; 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions", 4 | "ms-dotnettools.csharp" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /public/static/images/weather/cloud.dark.fog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/cloud.dark.fog.png -------------------------------------------------------------------------------- /public/static/images/weather/cloud.drizzle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/cloud.drizzle.png -------------------------------------------------------------------------------- /public/static/images/weather/sun.big.cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/sun.big.cloud.png -------------------------------------------------------------------------------- /public/static/images/weather/sun.rays.cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/sun.rays.cloud.png -------------------------------------------------------------------------------- /public/static/images/weather/sun.rays.small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/sun.rays.small.png -------------------------------------------------------------------------------- /public/static/images/weather/cloud.dark.rain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/cloud.dark.rain.png -------------------------------------------------------------------------------- /public/static/images/weather/cloud.dark.snow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/cloud.dark.snow.png -------------------------------------------------------------------------------- /src/components/UserControl/index.scss: -------------------------------------------------------------------------------- 1 | .persona-button { 2 | padding: 0; 3 | border: 0px; 4 | background: transparent; 5 | cursor: pointer; 6 | } 7 | -------------------------------------------------------------------------------- /public/static/images/weather/sun.big.cloud.drizzle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/sun.big.cloud.drizzle.png -------------------------------------------------------------------------------- /public/static/images/weather/sun.rays.small.cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/sun.rays.small.cloud.png -------------------------------------------------------------------------------- /public/static/images/weather/cloud.dark.multiple.lightning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/LiveMaps/HEAD/public/static/images/weather/cloud.dark.multiple.lightning.png -------------------------------------------------------------------------------- /src/components/MSLogo/MSLogo.scss: -------------------------------------------------------------------------------- 1 | .ms-link { 2 | display: flex; 3 | 4 | &:not(:last-child) { 5 | margin-right: 35px; 6 | } 7 | } 8 | 9 | .ms-logo { 10 | width: 108px; 11 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [project-title] Changelog 2 | 3 | 4 | # x.y.z (yyyy-mm-dd) 5 | 6 | *Features* 7 | * ... 8 | 9 | *Bug Fixes* 10 | * ... 11 | 12 | *Breaking Changes* 13 | * ... 14 | -------------------------------------------------------------------------------- /src/components/StatsSidebar/PieChart/PieChart.scss: -------------------------------------------------------------------------------- 1 | .piechart-legend { 2 | padding: 0px; 3 | margin: 0px; 4 | justify-content: space-evenly; 5 | display: flex; 6 | flex-direction: row; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/AppHeader/AppHeader.scss: -------------------------------------------------------------------------------- 1 | .app-header { 2 | height: 64px; 3 | padding: 0 24px; 4 | display: flex; 5 | flex-direction: row; 6 | align-items: center; 7 | border-bottom: 1px solid rgba(0, 0, 0, 0.2); 8 | } 9 | -------------------------------------------------------------------------------- /src/components/StatsSidebar/StatsSidebar.scss: -------------------------------------------------------------------------------- 1 | .stats-sidebar { 2 | display: flex; 3 | flex-direction: column; 4 | overflow: hidden auto; 5 | width: 400px; 6 | border-left: 1px solid rgba(0, 0, 0, 0.2); 7 | white-space: nowrap; 8 | } 9 | -------------------------------------------------------------------------------- /src/models/popoversData.ts: -------------------------------------------------------------------------------- 1 | export enum PopoverType { 2 | Warning = 'warning', 3 | } 4 | 5 | export interface PopoverData { 6 | type: PopoverType; 7 | isVisible: boolean; 8 | target?: string; 9 | title?: string; 10 | description?: string; 11 | } -------------------------------------------------------------------------------- /src/components/StatsSidebar/SliderChart/SliderChart.scss: -------------------------------------------------------------------------------- 1 | .slider-chart { 2 | display: flex; 3 | flex-direction: row; 4 | padding: 0 25px; 5 | 6 | .chart-container { 7 | flex: 1; 8 | } 9 | 10 | .label { 11 | align-self: center; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/NoResults/NoResults.scss: -------------------------------------------------------------------------------- 1 | .search-no-result { 2 | padding: 30px 12px; 3 | text-align: center; 4 | 5 | .title { 6 | font-size: 14px; 7 | line-height: 20px; 8 | color: #A19F9D; 9 | margin-top: 0; 10 | margin-bottom: 30px; 11 | } 12 | } -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /livemaps-api/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingExcludedTypes": "Request", 6 | "samplingSettings": { 7 | "isEnabled": true 8 | } 9 | } 10 | }, 11 | "functionTimeout": "00:05:00" 12 | } -------------------------------------------------------------------------------- /src/components/LayersSwitcher/Switch/Switch.scss: -------------------------------------------------------------------------------- 1 | .switch-container { 2 | display: flex; 3 | margin-bottom: 8px; 4 | align-items: baseline; 5 | 6 | .switch-label { 7 | flex: 1; 8 | font-weight: 400; 9 | margin: 0 16px; 10 | } 11 | } 12 | 13 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const app = express(); 4 | app.use(express.static(path.join(__dirname, 'build'))); 5 | app.get('/*', function (req, res) { 6 | res.sendFile(path.join(__dirname, 'build', 'index.html')); 7 | }); 8 | app.listen(8080); 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.deploySubpath": "livemaps-api/bin/Release/netcoreapp3.1/publish", 3 | "azureFunctions.projectLanguage": "C#", 4 | "azureFunctions.projectRuntime": "~3", 5 | "debug.internalConsoleOptions": "neverOpen", 6 | "azureFunctions.preDeployTask": "publish" 7 | } -------------------------------------------------------------------------------- /src/utils/statsSidebarUtils.ts: -------------------------------------------------------------------------------- 1 | const headerColorByGroupId: {[groupId: string]: string} = { 2 | 'alerts': '#D83B01', 3 | }; 4 | 5 | const DEFAULT_HEADER_COLOR = '#0078D4'; 6 | 7 | export const getHeaderColorByGroupId = (groupId: string) => { 8 | return headerColorByGroupId[groupId] ?? DEFAULT_HEADER_COLOR; 9 | }; -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to .NET Functions", 6 | "type": "coreclr", 7 | "request": "attach", 8 | "processId": "${command:azureFunctions.pickProcess}" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /livemaps-api/Models/Level.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace ssir.api.Models 6 | { 7 | public class Level 8 | { 9 | public Dictionary Rooms; 10 | public Dictionary Units; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/StatsSidebar/PieChart/LegendItem.scss: -------------------------------------------------------------------------------- 1 | .piechart-legend-item { 2 | margin-right: 10px; 3 | } 4 | 5 | .piechart-data-icon { 6 | vertical-align: top; 7 | margin-right: 4px; 8 | margin-top: 4px; 9 | } 10 | 11 | .piechart-data-text { 12 | display: inline-block; 13 | } 14 | 15 | .piechart-data-value { 16 | font-size: 16px; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/StatsSidebar/TextLabel/TextLabel.scss: -------------------------------------------------------------------------------- 1 | .stats-item { 2 | height: 46px; 3 | width: 100%; 4 | padding: 0 25px; 5 | display: flex; 6 | flex-direction: row; 7 | align-items: center; 8 | font-size: 14px; 9 | 10 | .label { 11 | flex: 1; 12 | cursor: default; 13 | } 14 | 15 | .value { 16 | font-weight: 600; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/validationUtils.ts: -------------------------------------------------------------------------------- 1 | import { number, NumberSchema, string, StringSchema } from 'yup'; 2 | 3 | export const notEmptyString: StringSchema = string().defined().strict(true).nullable(false).min(1); 4 | 5 | export const strictNumber: NumberSchema = number() 6 | .transform((value, origin) => typeof origin === 'number' ? origin : NaN) 7 | .defined() 8 | .nullable(false); -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Azure Live Map", 3 | "name": "Azure Live Map", 4 | "icons": [ 5 | { 6 | "src": "logo.png", 7 | "type": "image/png", 8 | "sizes": "192x192 64x64 32x32 24x24 16x16" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /livemaps-api/Models/Building.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace ssir.api.Models 6 | { 7 | public class Building 8 | { 9 | public Building() 10 | { 11 | Levels = new Dictionary(); 12 | } 13 | public Dictionary Levels; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/App.scss: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body, 7 | #root, 8 | .App { 9 | width: 100%; 10 | height: 100%; 11 | margin: 0; 12 | padding: 0; 13 | overflow: hidden; 14 | } 15 | 16 | .App { 17 | display: flex; 18 | flex-direction: column; 19 | } 20 | 21 | main { 22 | flex: 1; 23 | display: flex; 24 | flex-direction: row; 25 | min-height: 600px; 26 | } 27 | -------------------------------------------------------------------------------- /livemaps-api/Models/RoomDataItem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace ssir.api.Models 6 | { 7 | public class RoomDataItem 8 | { 9 | public string name { get; set; } 10 | public string type { get; set; } 11 | public string unitId { get; set; } 12 | public double[][] polygon { get; set; } 13 | 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/LayersSwitcher/LayerChildrenMenu/LayerChildrenMenu.scss: -------------------------------------------------------------------------------- 1 | .layers-switcher-morechildren { 2 | padding: 0 4px; 3 | margin: 0 8px; 4 | align-self: stretch; 5 | height: auto; 6 | } 7 | 8 | .layerchildren-callout { 9 | width: 200px; 10 | 11 | .layerchildren-search { 12 | border-bottom: 1px solid #ccc; 13 | 14 | .layerchildren-input { 15 | margin: 8px; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /livemaps-api/Models/SideBar/LabelItem.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | 6 | namespace ssir.api.Models.SideBar 7 | { 8 | public class LabelItem 9 | { 10 | [JsonProperty("type")] 11 | public string Type { get; set; } 12 | 13 | [JsonProperty("data")] 14 | public NameValueDataItem Data { get; set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /livemaps-api/Models/SideBar/NameValueDataItem.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | 6 | namespace ssir.api.Models.SideBar 7 | { 8 | public class NameValueDataItem 9 | { 10 | [JsonProperty("name")] 11 | public string Name { get; set; } 12 | 13 | [JsonProperty("value")] 14 | public string Value { get; set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/css/colors.scss: -------------------------------------------------------------------------------- 1 | $gray90 :#a19f9d; 2 | $gray130 :#605E5C; 3 | 4 | $primaryBlue :#0078D4; 5 | 6 | $lightGrayishBlue :#C7E0F4; 7 | $powderBlue :#B0E0E6; 8 | 9 | $goldenPoppy :#FCC200; 10 | $tangerineYellow :#FFCC00; -------------------------------------------------------------------------------- /livemaps-api/Models/Atlas/Geometry.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | 6 | namespace ssir.api.Models.Atlas 7 | { 8 | public partial class Geometry 9 | { 10 | [JsonProperty("type")] 11 | public string Type { get; set; } 12 | 13 | [JsonProperty("coordinates")] 14 | public double[][][] Coordinates { get; set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /livemaps-api/Models/Room.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace ssir.api.Models 6 | { 7 | public class Room 8 | { 9 | public Dictionary Devices = new Dictionary(); 10 | } 11 | 12 | public class Unit 13 | { 14 | public Dictionary Devices = new Dictionary(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/models/warningsData.ts: -------------------------------------------------------------------------------- 1 | import { MarkerData } from './mapData'; 2 | 3 | export interface WarningData extends MarkerData {} 4 | 5 | export interface WarningsByLayers { 6 | [layerId: string]: WarningData[] | undefined, 7 | } 8 | 9 | export interface WarningsByRooms { 10 | [roomName: string]: WarningsByLayers | undefined; 11 | } 12 | 13 | export interface WarningsByLocation { 14 | [locationId: string]: WarningsByRooms | undefined; 15 | } -------------------------------------------------------------------------------- /livemaps-api/Models/RoomItem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace ssir.api.Models 6 | { 7 | public class RoomItem 8 | { 9 | public string Region { get; set; } 10 | public string Campus { get; set; } 11 | public string Building { get; set; } 12 | public string Floor { get; set; } 13 | public string Room { get; set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /livemaps-api/Models/Warning.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace ssir.api.Models 6 | { 7 | public class Warning 8 | { 9 | public string title { get; set; } 10 | public string description { get; set; } 11 | public Position position { get; set; } 12 | public string url { get; set; } 13 | public double[][] polygon { get; internal set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Visual Studio files 4 | .vs/ 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /livemaps-api/Models/TagObject.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace ssir.api.Models 6 | { 7 | public class TagObject 8 | { 9 | public string DeviceId { get; set; } 10 | public double Value { get; set; } 11 | public System.DateTime timestamp { get; set; } 12 | public string MapFeatureId { get; set; } 13 | public string TagName { get; set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/css/scrollbar.scss: -------------------------------------------------------------------------------- 1 | @mixin scrollbars($size, $foreground-color, $background-color: mix($foreground-color, white, 20%)) { 2 | // For Google Chrome and Safari 3 | &::-webkit-scrollbar { 4 | width: $size; 5 | height: $size; 6 | } 7 | &::-webkit-scrollbar-thumb { 8 | background: $foreground-color; 9 | border-radius: 10px; 10 | } 11 | &::-webkit-scrollbar-track { 12 | background: $background-color; 13 | border-radius: 10px; 14 | } 15 | } -------------------------------------------------------------------------------- /livemaps-api/Services/SiteMapBuilder.cs: -------------------------------------------------------------------------------- 1 | using ssir.api.Models; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Data.SqlClient; 5 | using System.Text; 6 | 7 | namespace ssir.api.Services 8 | { 9 | public class SiteMapBuilder 10 | { 11 | public List BuildSiteMap(BuildingConfig[] atlasSubscriptionKey) 12 | { 13 | var levels = new List(); 14 | return levels; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/FavoriteLocationButton/FavoriteLocationButton.scss: -------------------------------------------------------------------------------- 1 | @import '../../css/colors.scss'; 2 | 3 | .favorite-location-button { 4 | background: transparent; 5 | color: $lightGrayishBlue; 6 | 7 | &:hover { 8 | background: transparent; 9 | color: $powderBlue; 10 | } 11 | 12 | &:focus { 13 | outline: none; 14 | } 15 | 16 | &.checked { 17 | color: $tangerineYellow; 18 | 19 | &:hover { 20 | color: $goldenPoppy; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/StatsSidebar/Alert/Alert.scss: -------------------------------------------------------------------------------- 1 | .alert-item { 2 | height: 46px; 3 | width: 100%; 4 | padding: 0 25px; 5 | display: flex; 6 | flex-direction: row; 7 | align-items: center; 8 | font-size: 14px; 9 | 10 | .link, label { 11 | flex: 1; 12 | } 13 | 14 | .link { 15 | text-decoration: underline; 16 | } 17 | 18 | .label { 19 | cursor: default; 20 | } 21 | 22 | .icon { 23 | padding-right: 8px; 24 | cursor: default; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /livemaps-api/Models/SideBar/Group.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | 6 | namespace ssir.api.Models.SideBar 7 | { 8 | public partial class Group 9 | { 10 | [JsonProperty("id")] 11 | public string Id { get; set; } 12 | 13 | [JsonProperty("name")] 14 | public string Name { get; set; } 15 | 16 | [JsonProperty("items")] 17 | public LabelItem[] Items { get; set; } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/AppHeader/AppHeader.tsx: -------------------------------------------------------------------------------- 1 | import './AppHeader.scss'; 2 | 3 | import React from 'react'; 4 | 5 | import LocationBreadcrumb from '../LocationBreadcrumb/LocationBreadcrumb'; 6 | import MSLogo from '../MSLogo/MSLogo'; 7 | import UserControl from '../UserControl'; 8 | 9 | const AppHeader: React.FC = () => { 10 | return ( 11 |
12 | 13 | 14 | 15 | 16 | 17 |
18 | ); 19 | }; 20 | 21 | export default AppHeader; 22 | 23 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # REACT_APP_MAP_SUBSCRIPTION_KEY = your-azure-maps-subscription-key 2 | 3 | REACT_APP_TRACKER_HOSTNAME = [actor-tracker-websites-hostname] 4 | REACT_APP_API_BASE_URL = [api-azurewebsite-host-name] 5 | 6 | # "{locationPath}" will be repalced with current location's path, e.g./pugetsound/westcampus/b121 7 | REACT_APP_ROOMS_DATA_URL=/roomdata/{locationPath} 8 | REACT_APP_SENSORDATA_URL=/state/{locationPath} 9 | REACT_APP_SITEMAP_URL=/sitemap 10 | REACT_APP_SIDEBAR_DATA_URL=/sidebar/{locationPath} 11 | REACT_APP_WARNINGS_DATA_URL=/faults/{locationPath} 12 | -------------------------------------------------------------------------------- /src/components/MSLogo/MSLogo.tsx: -------------------------------------------------------------------------------- 1 | import './MSLogo.scss'; 2 | 3 | import React from 'react'; 4 | import { Link } from '@fluentui/react'; 5 | 6 | const MSLogo: React.FC = () => { 7 | return ( 8 | 13 | logo 20 | 21 | ); 22 | }; 23 | 24 | export default MSLogo; 25 | 26 | -------------------------------------------------------------------------------- /src/utils/colorUtils.ts: -------------------------------------------------------------------------------- 1 | export const CHARTS_PALETTE = [ 2 | '#7F3C8D', '#E58606', '#096DD9', '#F8D149', '#FADB14', '#3969AC', '#E68310', 3 | '#CF1C90', '#29C7A1', '#D81A29', '#764E9F', '#389E0D', '#E73F74', '#4B4B8F', 4 | '#CC61B0', '#FF7C00', '#CC3A8E', '#0032FF', '#11A579', '#008695', '#5D69B1', 5 | '#8C8C8C', '#2B88D8', '#03BD5B', '#F2B701', '#F97B72', '#99C945', '#FF3700', 6 | '#ED645A', '#13C2C2', '#5FC41D', '#A5AA99', '#24796C', '#FFA407', '#A5AA99', 7 | '#EB2F96', 8 | ]; 9 | 10 | export const getChartColorByIndex = (index: number) => CHARTS_PALETTE[index % CHARTS_PALETTE.length]; -------------------------------------------------------------------------------- /livemaps-api/Models/Device.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace ssir.api.Models 6 | { 7 | public class Device 8 | { 9 | public string DeviceId { get; set; } 10 | public string EquipmentClass { get; set; } 11 | public string Equipment { get; set; } 12 | public List Tags = new List(); 13 | } 14 | 15 | public class DeviceTag 16 | { 17 | public string Tag { get; set; } 18 | public string Value { get; set; } 19 | public string TimeStamp { get; set; } 20 | 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/NoResults/NoResults.tsx: -------------------------------------------------------------------------------- 1 | import './NoResults.scss'; 2 | 3 | import React from 'react'; 4 | 5 | export interface NoResultsProps { 6 | title: string; 7 | } 8 | 9 | const NoResults: React.FC = ({ 10 | title, 11 | }) => { 12 | return ( 13 |
14 |

15 | {title} 16 |

17 | 18 | No search result image 24 |
25 | ); 26 | }; 27 | 28 | export default NoResults; 29 | -------------------------------------------------------------------------------- /src/models/roomsData.ts: -------------------------------------------------------------------------------- 1 | import { object, string } from 'yup'; 2 | 3 | import { Polygon, polygonSchema } from './mapData'; 4 | import { notEmptyString } from '../utils/validationUtils'; 5 | 6 | export interface RoomData { 7 | name?: string; 8 | type: string; 9 | unitId: string; 10 | polygon: Polygon; 11 | } 12 | 13 | export const RoomDataSchema = object().shape({ 14 | name: string(), 15 | type: notEmptyString, 16 | unitId: notEmptyString, 17 | polygon: polygonSchema, 18 | }).defined().nullable(false); 19 | 20 | export interface RoomsByFloorId { 21 | [floorId: string]: { 22 | [roomId: string]: RoomData | undefined; 23 | } | undefined; 24 | } -------------------------------------------------------------------------------- /src/components/SideNavBar/NavbarButton/NavbarButton.scss: -------------------------------------------------------------------------------- 1 | @import '../../../css/colors.scss'; 2 | 3 | .sidenav-button-button { 4 | min-width: 0px; 5 | height: 44px; 6 | width: 100%; 7 | border: 0px; 8 | padding: 0 14px; 9 | 10 | .sidenav-button-inner { 11 | justify-content: start; 12 | } 13 | 14 | .sidenav-button-icon { 15 | color: $primaryBlue; 16 | } 17 | 18 | .sidenav-button-text { 19 | flex: 1; 20 | text-align: start; 21 | } 22 | 23 | &:hover { 24 | color: $primaryBlue; 25 | } 26 | 27 | &:disabled { 28 | background-color: transparent; 29 | 30 | .sidenav-button-icon { 31 | color: $gray90; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/StatsSidebar/TextLabel/TextLabel.tsx: -------------------------------------------------------------------------------- 1 | import './TextLabel.scss'; 2 | 3 | import React from 'react'; 4 | import { Text } from '@fluentui/react'; 5 | 6 | export interface TextLabelProps { 7 | label: string 8 | value?: string | number, 9 | } 10 | 11 | export const TextLabel: React.FC = ({ label, value }) => { 12 | if (label.trim() === '') { 13 | return null; 14 | } 15 | 16 | return ( 17 |
18 | 19 | {label} 20 | 21 | 22 | {value && ( 23 | {value} 24 | )} 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/reducers/map.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { RootState } from '../store/store'; 3 | 4 | export interface MapState { 5 | zoomLevel?: number; 6 | }; 7 | 8 | const initialState: MapState = {}; 9 | 10 | export const mapSlice = createSlice({ 11 | name: 'map', 12 | initialState, 13 | reducers: { 14 | setMapZoomLevel: (state: MapState, action: PayloadAction) => ({ zoomLevel: action.payload }), 15 | }, 16 | }); 17 | 18 | export const { 19 | setMapZoomLevel, 20 | } = mapSlice.actions; 21 | 22 | export const selectMapZoomLevel = (state: RootState) => state.map.zoomLevel; 23 | 24 | export default mapSlice.reducer; 25 | -------------------------------------------------------------------------------- /src/components/StatsSidebar/SliderControl/SliderControl.scss: -------------------------------------------------------------------------------- 1 | .slider-item { 2 | width: 100%; 3 | padding: 13px 25px; 4 | display: flex; 5 | flex-direction: column; 6 | font-size: 14px; 7 | 8 | .label-container { 9 | display: flex; 10 | flex-direction: row; 11 | 12 | .label { 13 | flex: 1; 14 | cursor: default; 15 | } 16 | .value { 17 | font-weight: 600; 18 | } 19 | } 20 | .slider-container { 21 | display: flex; 22 | flex-direction: row; 23 | align-items: center; 24 | margin-top: 5px; 25 | 26 | .slider { 27 | flex: 1; 28 | } 29 | 30 | .slidebox { 31 | padding-left: 0px; 32 | } 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /src/types/cssAnimationSync.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'css-animation-sync' { 2 | export default class AnimationController { 3 | constructor(animationNames: string | string[]); 4 | 5 | /** 6 | * Stops synchronization of DOM elements using the animation 7 | */ 8 | public free(): void; 9 | 10 | /** 11 | * Pause the animation of DOM elements using the animation 12 | */ 13 | public pause(): void; 14 | 15 | /** 16 | * Stop the animation of DOM elements using the animation 17 | */ 18 | public stop(): void; 19 | 20 | /** 21 | * Start/Resume the animation of DOM elements using the animation 22 | */ 23 | public start(): void; 24 | }; 25 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react", 21 | "typeRoots": [ 22 | "node_modules/@types", 23 | "src/types" 24 | ] 25 | }, 26 | "include": [ 27 | "src" 28 | ], 29 | } 30 | -------------------------------------------------------------------------------- /livemaps-api/Models/Atlas/Feature.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | 6 | namespace ssir.api.Models.Atlas 7 | { 8 | public partial class Feature 9 | { 10 | [JsonProperty("type")] 11 | public string Type { get; set; } 12 | 13 | [JsonProperty("geometry")] 14 | public Geometry Geometry { get; set; } 15 | 16 | [JsonProperty("properties")] 17 | public Properties Properties { get; set; } 18 | 19 | [JsonProperty("id")] 20 | public string Id { get; set; } 21 | 22 | [JsonProperty("categoryId")] 23 | public string categoryId { get; set; } 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/components/StatsSidebar/GroupHeader/GroupHeader.scss: -------------------------------------------------------------------------------- 1 | @import '../../../css/colors.scss'; 2 | 3 | .stats-header { 4 | background-color: #fff; 5 | border: 0; 6 | height: 50px; 7 | width: 100%; 8 | padding: 0 25px; 9 | color: $primaryBlue; 10 | font-size: 14px; 11 | text-align: left; 12 | 13 | .flex-container { 14 | width: 100%; 15 | } 16 | 17 | .label { 18 | flex: 1; 19 | font-weight: 600; 20 | } 21 | 22 | .collapse-icon { 23 | color: $primaryBlue; 24 | transform-origin: 50% 50% 0px; 25 | transition: transform 0.1s linear 0s; 26 | transform: rotate(0deg); 27 | 28 | &.collapsed { 29 | transform: rotate(-90deg); 30 | } 31 | } 32 | } 33 | 34 | 35 | -------------------------------------------------------------------------------- /livemaps-api/Models/Atlas/FeatureCollection.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | 6 | namespace ssir.api.Models.Atlas 7 | { 8 | public class FeatureCollection 9 | { 10 | [JsonProperty("type")] 11 | public string Type { get; set; } 12 | 13 | [JsonProperty("features")] 14 | public Feature[] Features { get; set; } 15 | 16 | [JsonProperty("numberReturned")] 17 | public long NumberReturned { get; set; } 18 | 19 | public link[] links { get; set; } 20 | } 21 | 22 | public class link 23 | { 24 | public string href { get; set; } 25 | public string rel { get; set; } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/StatsSidebar/PieChart/LegendItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Icon, Text } from "@fluentui/react"; 3 | 4 | import "./LegendItem.scss"; 5 | 6 | interface LegendItemProps { 7 | name: string; 8 | value: string | number; 9 | color: string; 10 | } 11 | 12 | export const LegendItem: React.FC = ({ name, value, color }) => { 13 | return ( 14 |
15 | 16 |
17 | {name} 18 |
19 | { value && {value} } 20 |
21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /web.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/utils/warningsUtils.ts: -------------------------------------------------------------------------------- 1 | import { WarningsByLocation, WarningsByRooms, WarningsByLayers, WarningData } from "../models/warningsData"; 2 | 3 | export const getLayerWarnings = (data: WarningsByLocation, layerId: string, locationId: string) => { 4 | const locationWarnings: WarningsByRooms | undefined = data[locationId]; 5 | 6 | if (!locationWarnings) { 7 | return []; 8 | } 9 | 10 | const warnings: WarningData[] = []; 11 | 12 | for (const roomId in locationWarnings) { 13 | const roomWarnings: WarningsByLayers | undefined = locationWarnings[roomId]; 14 | 15 | if (!roomWarnings) { 16 | continue; 17 | } 18 | 19 | roomWarnings[layerId]?.forEach((warningData: WarningData, index: number) => { 20 | warnings.push(warningData); 21 | }); 22 | } 23 | 24 | return warnings; 25 | } -------------------------------------------------------------------------------- /src/components/SideNavBar/NavbarButton/NavbarButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DefaultButton, IButtonProps } from "@fluentui/react"; 3 | 4 | import "./NavbarButton.scss"; 5 | 6 | interface NavbarButtonProps { 7 | iconName?: string; 8 | } 9 | 10 | export const NavbarButton: React.FC = ({ iconName, iconProps, styles, ...rest }) => ( 11 | 25 | ); 26 | -------------------------------------------------------------------------------- /livemaps-api/Models/RecentDataItem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace ssir.api.Models 6 | { 7 | public class RecentDataItem 8 | { 9 | public string Region { get; set; } 10 | public string Campus { get; set; } 11 | public string Building { get; set; } 12 | public string Level { get; set; } 13 | public string Unit { get; set; } 14 | public string Room { get; set; } 15 | public string FeatureId { get; set; } 16 | public string DeviceId { get; set; } 17 | public string EquipmentClass { get; set; } 18 | public string Equipment { get; set; } 19 | public string Tag { get; set; } 20 | public string Value { get; set; } 21 | public string TimeStamp { get; set; } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/models/mapData.ts: -------------------------------------------------------------------------------- 1 | import { array, ArraySchema } from 'yup'; 2 | 3 | import { strictNumber } from '../utils/validationUtils'; 4 | 5 | export interface MapPosition { 6 | longitude: number; 7 | latitude: number; 8 | } 9 | 10 | export type Polygon = [number, number][]; 11 | 12 | const positionSchema: ArraySchema = array().of(strictNumber).min(2).max(2).defined().nullable(false); 13 | export const polygonSchema: ArraySchema = array().of(positionSchema).defined().nullable(false).min(3); 14 | 15 | export interface MarkerData { 16 | title?: string; 17 | description?: string; 18 | position?: MapPosition; 19 | url?: string; 20 | roomName?: string; 21 | } 22 | 23 | export interface TrackerData { 24 | id: string; 25 | name: string; 26 | position: MapPosition; 27 | } 28 | 29 | export interface MapObject { 30 | polygon?: Polygon; 31 | } -------------------------------------------------------------------------------- /src/components/FavoritesSwitcher/NoFavorites/NoFavorites.tsx: -------------------------------------------------------------------------------- 1 | import './NoFavorites.scss'; 2 | 3 | import React from 'react'; 4 | import { Callout, DirectionalHint } from '@fluentui/react'; 5 | 6 | import NoResults from '../../NoResults/NoResults'; 7 | 8 | export interface NoFavoritesProps { 9 | target: string | Element | MouseEvent | React.RefObject; 10 | onDismiss?: () => void; 11 | } 12 | 13 | export const NoFavorites: React.FC = ({ 14 | target, 15 | onDismiss, 16 | }) => { 17 | return ( 18 | 26 | 27 | 28 | ) 29 | }; -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build app 2 | 3 | on: 4 | pull_request: 5 | 6 | env: 7 | NODE_VERSION: '12.x' 8 | 9 | jobs: 10 | build: 11 | name: Build app 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ env.NODE_VERSION }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ env.NODE_VERSION }} 19 | - name: npm install, build, and test 20 | env: 21 | REACT_APP_MAP_SUBSCRIPTION_KEY: ${{ secrets.REACT_APP_MAP_SUBSCRIPTION_KEY }} 22 | run: | 23 | # Build and test the project, then 24 | # deploy to Azure Web App. 25 | npm install 26 | npm run build 27 | npm run test --if-present 28 | - name: Archive build artifacts 29 | uses: actions/upload-artifact@v1 30 | with: 31 | name: build 32 | path: build 33 | -------------------------------------------------------------------------------- /livemaps-api/Models/UnitInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace ssir.api.Models 6 | { 7 | class UnitInfo : FloorInfo 8 | { 9 | private Random rnd = new Random(); 10 | public string Unit { get; set; } 11 | public string UnitId 12 | { 13 | get 14 | { 15 | return $"{FloorId}-{Unit}".Replace(" ", "_"); 16 | } 17 | } 18 | public int HeadCount 19 | { 20 | get 21 | { 22 | return Math.Abs(rnd.Next(50) - 20); 23 | } 24 | } 25 | 26 | public int WorkOrdersCount 27 | { 28 | get 29 | { 30 | return Math.Abs(rnd.Next(20) - 13); 31 | } 32 | } 33 | 34 | public string DeviceName { get; set; } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { initializeIcons } from '@uifabric/icons'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { Provider } from 'react-redux'; 5 | import { BrowserRouter as Router } from "react-router-dom"; 6 | 7 | import App from './App'; 8 | import './index.scss'; 9 | import * as serviceWorker from './serviceWorker'; 10 | import { store } from './store/store'; 11 | 12 | initializeIcons(); 13 | 14 | ReactDOM.render( 15 | 16 | 17 | 18 | 19 | 20 | 21 | , 22 | document.getElementById('root') 23 | ); 24 | 25 | // If you want your app to work offline and load faster, you can change 26 | // unregister() to register() below. Note this comes with some pitfalls. 27 | // Learn more about service workers: https://bit.ly/CRA-PWA 28 | serviceWorker.unregister(); 29 | -------------------------------------------------------------------------------- /src/components/LayersSwitcher/Switch/Switch.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { getId, Toggle, Label } from "@fluentui/react"; 3 | 4 | import "./Switch.scss"; 5 | 6 | interface LayerSwitchProps { 7 | name: string 8 | isVisible: boolean 9 | onSetVisibility(on: boolean): void 10 | } 11 | 12 | export const Switch: React.FC = ({ name, isVisible, onSetVisibility, children }) => { 13 | const handleChanged = (ev: any, checked?: boolean) => onSetVisibility(!!checked); 14 | const toggleId = getId('toggle'); 15 | return ( 16 |
17 | 23 | {children} 24 | 30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/StatsSidebar/BarChart/BarChart.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Bar, BarChart as BarChartRecharts, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; 3 | 4 | export interface BarChartProps { 5 | data: { 6 | name: string; 7 | value: number; 8 | }[]; 9 | } 10 | 11 | const MAX_BAR_WIDTH = 30; 12 | const BAR_COLOR = '#0078D4'; 13 | 14 | export const BarChart: React.FC = ({ data }) => { 15 | if (!data.length) { 16 | return null; 17 | } 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | > Please provide us with the following information: 5 | > --------------------------------------------------------------- 6 | 7 | ### This issue is for a: (mark with an `x`) 8 | ``` 9 | - [ ] bug report -> please search issues before submitting 10 | - [ ] feature request 11 | - [ ] documentation issue or request 12 | - [ ] regression (a behavior that used to work and stopped in a new release) 13 | ``` 14 | 15 | ### Minimal steps to reproduce 16 | > 17 | 18 | ### Any log messages given by the failure 19 | > 20 | 21 | ### Expected/desired behavior 22 | > 23 | 24 | ### OS and Version? 25 | > Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?) 26 | 27 | ### Versions 28 | > 29 | 30 | ### Mention any other details that might be useful 31 | 32 | > --------------------------------------------------------------- 33 | > Thanks! We'll be in touch soon. 34 | -------------------------------------------------------------------------------- /livemaps-api/livemaps.api.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netcoreapp3.1 4 | v3 5 | ssir.api 6 | ssir.api 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | PreserveNewest 17 | 18 | 19 | PreserveNewest 20 | Never 21 | 22 | 23 | -------------------------------------------------------------------------------- /livemaps-api/Models/BuildingConfig.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 BuildingConfig 9 | { 10 | [JsonProperty("buildingId")] 11 | public string BuildingId { get; set; } 12 | 13 | [JsonProperty("subscriptionKey")] 14 | public string SubscriptionKey { get; set; } 15 | 16 | [JsonProperty("datasetId")] 17 | public string DatasetId { get; set; } 18 | 19 | [JsonProperty("tilesetId")] 20 | public Guid TilesetId { get; set; } 21 | 22 | [JsonProperty("stateSets")] 23 | public StateSet[] StateSets { get; set; } 24 | 25 | [JsonProperty("facilityId")] 26 | public string FacilityId { get; set; } 27 | } 28 | 29 | public partial class StateSet 30 | { 31 | [JsonProperty("stateSetName")] 32 | public string StateSetName { get; set; } 33 | 34 | [JsonProperty("stateSetId")] 35 | public Guid StateSetId { get; set; } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/components/StatsSidebar/GroupHeader/GroupHeader.tsx: -------------------------------------------------------------------------------- 1 | import './GroupHeader.scss'; 2 | 3 | import React from 'react'; 4 | import { DefaultButton as Button, Icon, IGroupHeaderProps, Text } from '@fluentui/react'; 5 | 6 | export interface GroupHeaderProps extends IGroupHeaderProps { 7 | color?: string; 8 | } 9 | 10 | export const GroupHeader: React.FC = ({ group, onToggleCollapse, color }) => { 11 | if (!group) { 12 | return null; 13 | } 14 | 15 | const { isCollapsed, name } = group; 16 | const className = `collapse-icon${isCollapsed ? " collapsed" : ""}`; 17 | const ariaLabel = `${isCollapsed ? "Expand" : "Colapse"} ${name} group`; 18 | 19 | return ( 20 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /livemaps-api/Models/Position.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace ssir.api.Models 6 | { 7 | public class Position 8 | { 9 | private double[][] coordinates; 10 | 11 | public Position() { } 12 | 13 | public Position(double[][] coordinates) 14 | { 15 | this.coordinates = coordinates; 16 | double sumLat = 0.0; 17 | double sumLng = 0.0; 18 | 19 | foreach(var coordinate in coordinates) 20 | { 21 | sumLat += coordinate[1]; 22 | sumLng += coordinate[0]; 23 | //{ 24 | // latitude = feature.Geometry.Coordinates[0][0][1], 25 | // longitude = feature.Geometry.Coordinates[0][0][0] 26 | //}; 27 | } 28 | latitude = sumLat / coordinates.Length; 29 | longitude = sumLng / coordinates.Length; 30 | } 31 | 32 | public double latitude { get; set; } 33 | public double longitude { get; set; } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/Map/Map.scss: -------------------------------------------------------------------------------- 1 | .pulseIcon { 2 | display: block; 3 | width: 10px; 4 | height: 10px; 5 | border-radius: 50%; 6 | background: rgb(255, 208, 0); 7 | border: 2px solid white; 8 | cursor: pointer; 9 | box-shadow: 0 0 0 rgba(0, 204, 255, 0.4); 10 | animation: pulse 3s infinite; 11 | 12 | &:hover { 13 | animation: none; 14 | } 15 | } 16 | 17 | .trackerIcon { 18 | display: block; 19 | font-size: 16px; 20 | line-height: 16px; 21 | text-align: center; 22 | border: 2px solid white; 23 | border-radius: 50%; 24 | background: #ffd000; 25 | } 26 | 27 | .trackerIcon-popup { 28 | padding: 10px; 29 | } 30 | 31 | #mapparent { 32 | height: 100%; 33 | flex: 1; 34 | display: flex; 35 | flex-direction: column; 36 | } 37 | 38 | #map-id { 39 | position: relative; 40 | width: 100%; 41 | height: 100%; 42 | float: right; 43 | } 44 | 45 | @keyframes pulse { 46 | 0% { 47 | box-shadow: 0 0 0 0 rgb(255, 0, 0); 48 | } 49 | 50 | 70% { 51 | box-shadow: 0 0 0 20px rgba(255, 0, 0, 0); 52 | } 53 | 54 | 100% { 55 | box-shadow: 0 0 0 0 rgba(255, 0, 0, 0); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /livemaps-api/Models/Atlas/Properties.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | 6 | namespace ssir.api.Models.Atlas 7 | { 8 | public partial class Properties 9 | { 10 | [JsonProperty("originalId")] 11 | public Guid OriginalId { get; set; } 12 | 13 | [JsonProperty("categoryId")] 14 | public string CategoryId { get; set; } 15 | 16 | [JsonProperty("isOpenArea")] 17 | public bool IsOpenArea { get; set; } 18 | 19 | [JsonProperty("navigableBy")] 20 | public string[] NavigableBy { get; set; } 21 | 22 | [JsonProperty("routeThroughBehavior")] 23 | public string RouteThroughBehavior { get; set; } 24 | 25 | [JsonProperty("levelId")] 26 | public string LevelId { get; set; } 27 | 28 | [JsonProperty("occupants")] 29 | public object[] Occupants { get; set; } 30 | 31 | [JsonProperty("addressId")] 32 | public string AddressId { get; set; } 33 | 34 | [JsonProperty("name")] 35 | public string Name { get; set; } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | * ... 4 | 5 | ## Does this introduce a breaking change? 6 | 7 | ``` 8 | [ ] Yes 9 | [ ] No 10 | ``` 11 | 12 | ## Pull Request Type 13 | What kind of change does this Pull Request introduce? 14 | 15 | 16 | ``` 17 | [ ] Bugfix 18 | [ ] Feature 19 | [ ] Code style update (formatting, local variables) 20 | [ ] Refactoring (no functional changes, no api changes) 21 | [ ] Documentation content changes 22 | [ ] Other... Please describe: 23 | ``` 24 | 25 | ## How to Test 26 | * Get the code 27 | 28 | ``` 29 | git clone [repo-address] 30 | cd [repo-name] 31 | git checkout [branch-name] 32 | npm install 33 | ``` 34 | 35 | * Test the code 36 | 37 | ``` 38 | ``` 39 | 40 | ## What to Check 41 | Verify that the following are valid 42 | * ... 43 | 44 | ## Other Information 45 | -------------------------------------------------------------------------------- /src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { Action, configureStore, ThunkAction, getDefaultMiddleware } from '@reduxjs/toolkit'; 2 | 3 | import indoor from '../reducers/indoor'; 4 | import layersData from '../reducers/layersData'; 5 | import locationData from '../reducers/locationData'; 6 | import map from '../reducers/map'; 7 | import popovers from '../reducers/popover'; 8 | import rooms from '../reducers/rooms'; 9 | import sensors from '../reducers/sensors'; 10 | import sidebar from '../reducers/sidebar'; 11 | import user from '../reducers/user'; 12 | import warnings from '../reducers/warnings'; 13 | 14 | export const store = configureStore({ 15 | reducer: { 16 | indoor, 17 | layersData, 18 | locationData, 19 | map, 20 | sensors, 21 | sidebar, 22 | user, 23 | popovers, 24 | warnings, 25 | rooms, 26 | }, 27 | middleware: [...getDefaultMiddleware({ 28 | immutableCheck: false, 29 | serializableCheck: false, 30 | })] 31 | }); 32 | 33 | export type RootState = ReturnType; 34 | export type AppThunk = ThunkAction< 35 | ReturnType, 36 | RootState, 37 | unknown, 38 | Action 39 | >; 40 | -------------------------------------------------------------------------------- /src/components/SideNavBar/SideNavBar.scss: -------------------------------------------------------------------------------- 1 | @import '../../css/colors.scss'; 2 | 3 | .sidenav-bar { 4 | width: 280px; 5 | height: 100%; 6 | display: flex; 7 | flex-direction: column; 8 | border-right: 1px solid rgba(0, 0, 0, 0.2); 9 | transition: width 0.1s ease-out; 10 | 11 | .sidenav-group-content { 12 | margin: 0; 13 | } 14 | 15 | &.collapsed { 16 | width: 50px; 17 | } 18 | 19 | .top-group { 20 | flex: 1; 21 | } 22 | 23 | .favorites-button-icon { 24 | color: $tangerineYellow; 25 | } 26 | 27 | .sidenav-link { 28 | background-color: #fff; 29 | 30 | span { 31 | padding-left: 10px; 32 | } 33 | } 34 | 35 | .collapse-button-container { 36 | display: flex; 37 | flex-direction: row; 38 | justify-content: flex-end; 39 | 40 | button { 41 | width: 48px; 42 | min-width: 48px; 43 | height: 48px; 44 | background-color: #fff; 45 | border: 0px; 46 | } 47 | 48 | .collapse-icon { 49 | transition: transform 0.1s linear 0s; 50 | transform: rotate(0deg); 51 | &.collapsed { 52 | transform: rotate(180deg); 53 | } 54 | } 55 | } 56 | } 57 | 58 | -------------------------------------------------------------------------------- /src/components/Map/WarningPopover/WarningPopover.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | 4 | import Popover from '../Popover'; 5 | import { LocationData } from '../../../models/locationsData'; 6 | import { selectCurrentLocationData } from '../../../reducers/locationData'; 7 | import { PopoverData, PopoverType } from '../../../models/popoversData'; 8 | import { hidePopover, selectWarningPopoverData } from '../../../reducers/popover'; 9 | 10 | export const WarningPopover = () => { 11 | const currentLocation: LocationData | undefined= useSelector(selectCurrentLocationData); 12 | const data: PopoverData = useSelector(selectWarningPopoverData); 13 | const dispatch = useDispatch(); 14 | 15 | if (!currentLocation || !data.isVisible || !data.target) { 16 | return null; 17 | } 18 | 19 | return ( 20 | 24 | {currentLocation.name} {data.description ?? ''} 25 | 26 | } 27 | target={data.target} 28 | onDismiss={() => { 29 | dispatch(hidePopover(PopoverType.Warning)); 30 | }} 31 | /> 32 | ); 33 | } -------------------------------------------------------------------------------- /src/models/locationsData.ts: -------------------------------------------------------------------------------- 1 | import { MapPosition } from './mapData'; 2 | 3 | export enum LocationType { 4 | Global = 'global', 5 | Region = 'region', 6 | Campus = 'campus', 7 | Building = 'building', 8 | Floor = 'floor', 9 | } 10 | 11 | interface LocationConfig { 12 | buildingId?: string; 13 | facilityId?: string; 14 | tilesetId?: string; 15 | stateSets?: { 16 | stateSetName: string; 17 | stateSetId: string; 18 | }[]; 19 | } 20 | 21 | export interface LocationData extends MapPosition { 22 | id: string; 23 | name: string; 24 | parent?: LocationData; 25 | type: LocationType; 26 | area: number; 27 | items?: string[]; 28 | ordinalNumber?: number; 29 | config?: LocationConfig; 30 | } 31 | 32 | export interface RawLocationData extends Omit { 33 | parentId?: string; 34 | } 35 | 36 | export type AllLocationsData = { [id: string]: LocationData | undefined }; 37 | export type RawLocationsData = { [id: string]: RawLocationData | undefined }; 38 | 39 | export const DEFAULT_LOCATION = { 40 | id: "global", 41 | name: "Global", 42 | type: LocationType.Global, 43 | area: 45058050.3, 44 | latitude: 50.104882, 45 | longitude: 32.66734, 46 | }; 47 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE -------------------------------------------------------------------------------- /livemaps-api.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.29806.167 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "livemaps.api", "livemaps-api\livemaps.api.csproj", "{1FB0ED5C-5205-466D-9F9C-AC836D0C06C5}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {1FB0ED5C-5205-466D-9F9C-AC836D0C06C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {1FB0ED5C-5205-466D-9F9C-AC836D0C06C5}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {1FB0ED5C-5205-466D-9F9C-AC836D0C06C5}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {1FB0ED5C-5205-466D-9F9C-AC836D0C06C5}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {6C504BAA-8E3E-4357-A0C2-E07F85EFFB54} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /livemaps-api/Properties/PublishProfiles/ssirapi - Web Deploy.pubxml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | MSDeploy 8 | AzureWebSite 9 | Release 10 | Any CPU 11 | http://ssirapi.azurewebsites.net 12 | False 13 | $ssirapi 14 | <_SavePWD>True 15 | False 16 | ssirapi.scm.azurewebsites.net:443 17 | WMSVC 18 | False 19 | True 20 | True 21 | ssirapi 22 | 23 | -------------------------------------------------------------------------------- /src/components/StatsSidebar/Alert/Alert.tsx: -------------------------------------------------------------------------------- 1 | import './Alert.scss'; 2 | 3 | import React from 'react'; 4 | import { Icon, Link, Text } from '@fluentui/react'; 5 | 6 | export interface AlertProps { 7 | label: string 8 | url?: string; 9 | iconName?: string, 10 | iconColor?: string; 11 | } 12 | 13 | export const Alert: React.FC = ({ 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 | 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 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 |
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 |
124 |
125 | | undefined, value: string | undefined) => { 133 | setSearchValue(value ?? ''); 134 | }} 135 | ariaLabel={`Search ${groupName ?? ''}`} 136 | /> 137 |
138 | 139 | {filteredItems.length !== 0 && ( 140 |
    141 | {filteredItems.map((item) => ( 142 |
  • 143 | {renderSearchItem(item)} 144 |
  • 145 | ))} 146 |
147 | )} 148 | 149 | {!filteredItems.length && ( 150 | 151 | )} 152 | 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 | --------------------------------------------------------------------------------