Commit 9e4c6a25 authored by Hanadi's avatar Hanadi
Browse files

Initial Commit

parents
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
.App {
width: 100vw;
height: 100vh;
display: flex;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
import React from "react";
import {Sidebar} from "./components/Sidebar";
import "./App.css";
const App = () => (<div className={"App"}>
<Sidebar/>
</div>);
export default App;
import tinygradient from "tinygradient";
import {Color as CesiumColor} from "cesium";
import React from "react";
import {AQI_RANGES, getLabelAtValue, range} from "./Helpers";
import {Typography} from "@material-ui/core";
import {HtmlTooltip} from "./HtmlToolip";
type ColorValue = {
value: number;
color: string;
}
export class ColorGradient {
private readonly colorGradient: tinygradient.Instance;
private readonly values: ColorValue[];
private readonly min: number;
private readonly spread: number;
private customLegend?: JSX.Element;
get max(): number {return this.min + this.spread;}
get mid(): number {return this.min + this.spread / 2;}
constructor(
public readonly label: string,
values: ColorValue[],
private defaultColorCss: string = "#ffffff"
) {
this.values = values.sort((a, b) => a.value - b.value);
this.spread = values[values.length - 1].value - values[0].value;
this.min = values[0].value;
const stops = this.values.map(cv => ({
color: cv.color, pos: this.normalize(cv.value)
}));
this.colorGradient = tinygradient(stops);
}
getColorAtAsCssHex(value?: number, defaultColor?: string, darken?: boolean): string {
if (value === undefined) {
return defaultColor || this.defaultColorCss;
}
let color = this.colorGradient.rgbAt(this.normalize(value));
if (darken && color.isLight()) {
color = color.darken();
}
return "#" + color.toHex();
}
getColorAtAsCesiumColor(value?: number): CesiumColor {
return CesiumColor.fromCssColorString(this.getColorAtAsCssHex(value));
}
getLegendComponent(componentWidth: number = 200) {
if (this.customLegend) {
return (<div key={`gradient-${this.label}`} style={{marginBottom: 10}}>
{this.customLegend}
</div>);
}
const count = 50;
return (<div key={`gradient-${this.label}`} style={{marginBottom: 10}}>
<div style={{height: 15, display: "flex", flexDirection: "row"}}>
<span>{getLabelAtValue(this.min)}</span>
<span style={{flex: 1}}/>
<span>{getLabelAtValue(this.mid)}</span>
<span style={{flex: 1}}/>
<span>{getLabelAtValue(this.max)}</span>
</div>
<div style={{height: 10}}>
{range(this.min, this.max, count).map(value => (
<span key={`grad-${value}`} style={{
display: "inline-block",
backgroundColor: this.getColorAtAsCssHex(value),
width: componentWidth / count,
height: 5
}}/>
))}
</div>
<div style={{textAlign: "center", marginTop: 8}}>{this.label}</div>
</div>);
}
setCustomLegend(element: JSX.Element): ColorGradient {
this.customLegend = element;
return this;
}
private normalize(value: number) {
return Math.max(Math.min((value - this.min) / this.spread, 1), 0);
}
}
export const DEFAULT_BUILDINGS_COLOR_CSS = "#d4faff";
export const heatingColorGradient = new ColorGradient(
"Heating Co2 (kgCO2eq/day)",
[
{value: 0, color: "#ffbbbb"},
{value: 3000, color: "#ff0000"},
],
DEFAULT_BUILDINGS_COLOR_CSS);
export const jamFactorColorGradient = new ColorGradient("Jam Factor", [
{value: 0, color: "#00ff00"},
{value: 4, color: "#ffff00"},
{value: 10, color: "#ff0000"},
]);
export const speedColorGradient = new ColorGradient("Speed (km/h)", [
{value: 0, color: "#ff0000"},
{value: 40, color: "#ffff00"},
{value: 60, color: "#00ff00"},
{value: 100, color: "#00ff00"},
]);
export const trafficCo2ColorGradient = new ColorGradient("Traffic Co2 (kgCO2eq/day)", [
{value: 0, color: "#ffbbbb"},
{value: 3000, color: "#ff0000"}
]);
export const pmTrafficColorGradient = new ColorGradient("Traffic PM2.5 (kg/day)", [
{value: 0, color: "#00ff00"},
{value: 150, color: "#ff8c00"},
{value: 300, color: "#ff0000"},
]);
export const pmSensorColorGradient = new ColorGradient("PM2.5 (µg/m³)", [
{value: 0, color: "#00ff00"},
{value: 30, color: "#ff8c00"},
{value: 100, color: "#ff0000"},
]);
export const aqiSensorColorGradient = new ColorGradient("PM2.5 (µg/m³)",
AQI_RANGES.flatMap(range => [
{value: range.aqiRange[0], color: range.color},
{value: range.aqiRange[1], color: range.color},
]),
).setCustomLegend(
<div>
<div style={{
border: "1px solid #adadad",
borderRadius: 3,
padding: 5,
width: 200
}}>
{AQI_RANGES.map((aqi, idx) => {
let rangeStr = `${aqi.aqiRange[0]} - ${aqi.aqiRange[1]}`;
if (idx === AQI_RANGES.length - 1) { // last
rangeStr = ` ≥ ${aqi.aqiRange[0]}`;
}
return <div key={`strategy-${aqi.name}`}
style={{display: "flex", alignItems: "center", marginTop: 2}}>
<span
style={{
display: "inline-block",
width: 12,
height: 12,
backgroundColor: aqi.color,
border: "1px solid black",
}}/>
<span style={{marginLeft: 5, flex: 1}}>{aqi.name}</span>
<HtmlTooltip
placement={"top-end"}
title={<div>
<Typography variant={"body1"}>{aqi.name} ({rangeStr})</Typography>
<hr/>
<p>{aqi.description}</p>
</div>}
style={{backgroundColor: "#f5f5f9"}}
>
<span style={{marginLeft: 10, cursor: "pointer"}}>&#9432;</span>
</HtmlTooltip>
</div>;
}
)}
</div>
<div style={{textAlign: "center"}}>Air Quality Index</div>
</div>
);
// Speed Strategies
type StrategyData = {
color: string;
title: string;
description: JSX.Element;
range: [number, number];
}
// TODO: fix values
export const speedStrategies: StrategyData[] = [
{
color: "#e39d13",
title: "Low Speeds",
description: <ul>
<li>
<b>Congestion Migration Strategy: </b>
Increase the average traffic speeds from slower, heavily-congested speeds.
Examples include include ramp metering and incident management.
</li>
<li>
<b>Traffic Flow Smoothing Techniques: </b>
Eliminate the stop-and-go effect regarded as shock wave suppression, which also help
reduce the number of severity of individual accelerations and decelerations.
</li>
</ul>,
range: [0, 55]
},
{
color: "#00ff00",
title: "Moderate Speeds",
description: <div>No recommendation.</div>,
range: [55, 105]
},
{
color: "#ff0000",
title: "Very High Speeds",
description: <div>
<b>Speed Management Techniques: </b>
Reduce excessively high speeds to safe speeds. E.g. direct enforcement by police,
radar camera, or aircraft.
</div>,
range: [105, 150]
},
];
export const speedStrategyColorGradient = new ColorGradient(
"Speed Strategy",
speedStrategies.flatMap(strategy => [
{value: strategy.range[0] + 0.001, color: strategy.color},
{value: strategy.range[1], color: strategy.color}
])
).setCustomLegend(
<div>
{speedStrategies.map((strategy, idx) => {
let speedRange = `${strategy.range[0]} - ${strategy.range[1]} km/h`;
if (idx === speedStrategies.length - 1) { // last
speedRange = ` > ${strategy.range[0]} km/h`;
}
return <div key={`strategy-${strategy.title}`}
style={{display: "flex", alignItems: "center", marginTop: 2}}>
<span
style={{
display: "inline-block",
width: 12,
height: 12,
backgroundColor: strategy.color,
border: "1px solid black",
}}/>
<span style={{marginLeft: 5}}>{strategy.title}</span>
<span style={{flex: 1}}/>
<HtmlTooltip
placement={"top-end"}
title={<div>
<Typography variant={"body1"}>{strategy.title} ({speedRange})</Typography>
<hr/>
<p>{strategy.description}</p>
</div>}
style={{backgroundColor: "#f5f5f9"}}
>
<span style={{marginLeft: 10, cursor: "pointer"}}>&#9432;</span>
</HtmlTooltip>
</div>;
}
)}
</div>
);
const POLYGON = [
{long: 9.167607, lat: 48.778201},
{long: 9.185958, lat: 48.769639},
{long: 9.207777, lat: 48.773653},
{long: 9.236923, lat: 48.790480},
{long: 9.219015, lat: 48.805266},
{long: 9.203548, lat: 48.817967},
{long: 9.172679, lat: 48.811559},
];
const POLYGON_STR =
POLYGON.concat([POLYGON[0]])
.map(location => [location.long, location.lat])
.map(p => `${p[0]} ${p[1]}`).join(",");
const POLYGON_STR_REVERSED =
POLYGON.concat([POLYGON[0]])
.map(location => [location.long, location.lat])
.map(p => `${p[1]} ${p[0]}`).join(",");
export const ST_WITHIN_FILTER = "st_within(feature, geography'POLYGON " +
`((${POLYGON_STR}))')`;
export const ST_WITHIN_FILTER_REVERSED = "st_within(feature, geography'POLYGON " +
`((${POLYGON_STR_REVERSED}))')`;
import {getObservationUrlForFeatureOfInterest, getTrafficGeoJsonUrlForFeaturesWithinBBox, getTrafficHistoryUrl} from "./TrafficConfig";
import {FeatureCollection, GeoJsonProperties, Point} from "geojson";
import {getPMGeoJsonUrlWithLastObservation, getPMHistoryUrl} from "./PMConfig";
import {parseISO, subHours} from "date-fns";
import {getAQIFromPM} from "./Helpers";
const PM_RESULT_POST_KEY = "result";
const TRAFFIC_LENGTH_KEY = "properties/length";
export async function getTrafficGeoJsonWithAverages(
fromDate?: Date,
toDate?: Date,
fromSpeed?: number,
toSpeed?: number,
fromJamFactor?: number,
toJamFactor?: number,
): Promise<{ geoJson: FeatureCollection, isFiltered: boolean }> {
const filtered = ![fromDate, toDate, fromSpeed, toSpeed, fromJamFactor, toJamFactor].every(x => x === undefined);
const limitObservations = filtered ? 1000 : 1;
const geoJsonUrl = getTrafficGeoJsonUrlForFeaturesWithinBBox();
return fetch(geoJsonUrl)
.then(res => res.json())
.then((geoJson: FeatureCollection) => {
const promises = geoJson.features.map(async feature => {
if (!feature.properties)
return;
const id = feature.properties.id;
const additionalProperties = await getAveragesForFeatureOfInterest(
id,
limitObservations,
fromDate,
toDate,
fromSpeed,
toSpeed,
fromJamFactor,
toJamFactor,
);
feature.properties = {
id: feature.properties.id,
name: feature.properties.name,
length: feature.properties[TRAFFIC_LENGTH_KEY],
description: feature.properties.description,
...additionalProperties
};
});
return Promise.all(promises).then(() => {
geoJson.features = geoJson.features.filter(feature => feature.properties?.hasData);
return {geoJson, isFiltered: filtered};
});
});
}
export async function getAveragesForFeatureOfInterest(
id: string,
limitObservations: number,
fromDate?: Date,
toDate?: Date,
fromSpeed?: number,
toSpeed?: number,
fromJamFactor?: number,
toJamFactor?: number,
) {
type ItemType = { resultQuality: number, result: number, parameters: { jamFactor: number } }
let hasNext = true;
let data: ItemType[] = [];
let skipObservations = 0;
let maxPages = 3;
while (hasNext) {
const response = await fetch(getObservationUrlForFeatureOfInterest(
id,
fromDate,
toDate,
fromSpeed,
toSpeed,
fromJamFactor,
toJamFactor,
limitObservations,
skipObservations,
)).then(res => res.json());
data = data.concat(response.value);
maxPages--;
if (limitObservations < 1000 || !response["@iot.nextLink"] || !maxPages) {
hasNext = false;
} else {
skipObservations += limitObservations;
}
}
console.log(data);
return Promise.resolve(data)
.then((values: ItemType[]) => {
const speedAcc = new Accumulator();
const qualityAcc = new Accumulator();
const jamAcc = new Accumulator();
if (!values.length) {
return {
hasData: false
};
}
values.forEach(({result, parameters, resultQuality}) => {
speedAcc.accumulate(result);
qualityAcc.accumulate(resultQuality);
jamAcc.accumulate(parameters.jamFactor);
});
return {
hasData: true,
speed: speedAcc.getAverage(),
jamFactor: jamAcc.getAverage(),
quality: qualityAcc.getAverage(),
};
});
}
type TrafficistoryResult = {
phenomenonTime: Date;
speed: number;
jam: number;
};
export async function getTrafficHistory(
id: string,
fromDate: Date,
toDate: Date,
): Promise<TrafficistoryResult[]> {
const url = getTrafficHistoryUrl(
id,
fromDate,
toDate,
5000,
);
return fetch(url)
.then(res => res.json())
.then((json: any) => json.value)
.then((values: { phenomenonTime: string, result: number, parameters: { jamFactor: number } }[]) =>
values.map(it => ({
phenomenonTime: parseISO(it.phenomenonTime),
speed: it.result,
jam: it.parameters.jamFactor,
}))
);
}
export async function getPMGeoJsonWithAverages(
fromDate?: Date,
toDate?: Date,
fromPMValue?: number,
toPMValue?: number,
): Promise<{ geoJson: FeatureCollection, isFiltered: boolean }> {
const filtered = ![fromDate, toDate, fromPMValue, toPMValue].every(x => x === undefined);
const limitObservations = filtered ? 100 : 1;
if (!fromDate && !toDate) {
toDate = new Date();
fromDate = subHours(toDate, 1);
}
const url = getPMGeoJsonUrlWithLastObservation(
fromDate,
toDate,
fromPMValue,
toPMValue,
limitObservations
);
return fetch(url)
.then(res => res.json())
.then((geoJson: FeatureCollection) => {
geoJson.features.forEach(feature => {
const geometry = feature.geometry as Point;
const [latitude, longitude] = geometry.coordinates;
geometry.coordinates = [longitude, latitude];
const pm = getPMAverage(feature.properties);
const aqi = getAQIFromPM(pm);
if (!feature.properties)
return;
feature.properties = {
id: feature.properties.id,
name: feature.properties.name,
description: feature.properties.description,
longitude,
latitude,
pm,
aqi
};
});
return {geoJson, isFiltered: filtered};
});
}
type PMHistoryResult = { phenomenonTime: Date, result: number };
export async function getPMHistory(
id: number,
fromDate: Date,
toDate: Date,
): Promise<PMHistoryResult[]> {
const url = getPMHistoryUrl(
id,
fromDate,
toDate,
1000
);
return fetch(url)
.then(res => res.json())
.then((json: any) => json.value)
.then((values: { phenomenonTime: string, result: number }[]) =>
values.map(it => ({
phenomenonTime: parseISO(it.phenomenonTime),
result: it.result
}))
);
}
class Accumulator {
private count: number = 0;
private sum: number = 0;
accumulate(value: number) {
this.count++;
this.sum += value;
}
getAverage(): number {
return this.sum / this.count;
}
}
function getPMAverage(properties: GeoJsonProperties) {
if (!properties)
throw new Error("Unexpected null properties");
const pmAcc = new Accumulator();
Object.entries(properties).forEach(([key, property]) => {
if (key.endsWith(PM_RESULT_POST_KEY)) {
pmAcc.accumulate(+property);
}
}
);
return pmAcc.getAverage();
}
import {Cartesian3} from "cesium";
import {format} from "date-fns";
const AVERAGE_CARS_PER_DAY_IN_STUTTGART = 62_000;
export const BLACK_CSS = "black";
export type Dictionary<T> = {
[key: string]: T
}
export function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
export function arraySum(arr: number[]): number {
return arr.reduce(function (a, b) {
return a + b;
}, 0);
}
export function arrayAverage(arr: number[]): number {
return arr.length ? arraySum(arr) / arr.length : 0;
}
export function arrayMin(arr: number[]): number | undefined {
return arr.length ? arr.reduce(function (a, b) {
return a < b ? a : b;
}) : undefined;
}
export function arrayMax(arr: number[]): number | undefined {
return arr.length ? arr.reduce(function (a, b) {
return a > b ? a : b;
}) : undefined;
}
export function randomString(length: number) {
let result = "";
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function chunkArray<T>(arr: T[], size: number): T[][] {
return Array.from({length: Math.ceil(arr.length / size)}, (v, i) =>
arr.slice(i * size, i * size + size)
);
}
export function distanceSquaredInMeters(lon1: number, lat1: number, lon2: number, lat2: number): number {
const start = Cartesian3.fromDegrees(lon1, lat1);
const end = Cartesian3.fromDegrees(lon2, lat2);
return Cartesian3.distanceSquared(start, end);
}
export function encodeFrostId(id: string) {
return id.replaceAll("+", "%2B");
}
export function roundTo(n?: number, decimalPoints: number = 2): number | undefined {
if (n === undefined) return undefined;
const factor = 10 ** decimalPoints;
return Math.round(n * factor) / factor;
}
export function formatTimestamp(timestamp: number, formatStr = "dd/MM HH:mm") {
const d = new Date(timestamp);
try {
return format(d, formatStr);
} catch {
return "";
}
}
export function numberWithCommas(n?: number, roundToDecimalPoints: number = 2): string | undefined {
n = roundTo(n, roundToDecimalPoints);
return n?.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
export function kgCo2PerDayFactorFromSpeed(speed: number, segmentLength: number) {
return speed
* segmentLength
* 0.5302 // Factor
* 0.001 // g to kg
* AVERAGE_CARS_PER_DAY_IN_STUTTGART;
}
export function pmFactorFromSpeed(speed: number, segmentLength: number) {
return speed
* segmentLength
* 0.04 // Factor
* 0.001 // g to kg
* AVERAGE_CARS_PER_DAY_IN_STUTTGART;
}
export function getLabelAtValue(value?: number): string | undefined {
if (value === undefined)
return undefined;
const ONE_MILLION = 1_000_000;
const ONE_THOUSAND = 1_000;
if (value >= ONE_MILLION) {
return Math.floor(10 * value / ONE_MILLION) / 10 + "M";
}
if (value >= ONE_THOUSAND) {
return Math.floor(10 * value / ONE_THOUSAND) / 10 + "k";
}
return value.toString();
}
export function range(from: number, to: number, count: number = 10) {
const step = (to - from) / (count - 1);
return [...Array(count)].map((_, i) => from + i * step);
}
type Range = [number, number];
export type AQIRange = {
name: string;
description: string;
color: string;
aqiRange: Range;
pmRange: Range;
}
export const AQI_RANGES: AQIRange[] = [
{
aqiRange: [0, 50],
pmRange: [0, 10],
color: "#00ff00",
name: "Good",
description: "Air quality is considered satisfactory, and air pollution poses little or no risk.",
},
{
aqiRange: [51, 100],
pmRange: [11, 20],
color: "#ffff00",
name: "Moderate",
description: "Air quality is acceptable; however, for some pollutants there may be a moderate health concern for a very small number " +
"of people who are unusually sensitive to air pollution.",
},
{
aqiRange: [101, 150],
pmRange: [21, 25],
color: "#ff8400",
name: "Unhealthy for Sensitive Groups",
description: "Members of sensitive groups may experience health effects. The general public is not likely to be affected.",
},
{
aqiRange: [151, 200],
pmRange: [26, 50],
color: "#ff0000",
name: "Unhealthy",
description: "Everyone may begin to experience health effects; members of sensitive groups may experience more serious health effects.",
},
{
aqiRange: [201, 300],
pmRange: [51, 100],
color: "#b103b1",
name: "Very Unhealthy",
description: "Health warnings of emergency conditions. The entire population is more likely to be affected.",
},
];
function isInRange(n: number, range: Range) {
return n >= range[0] && n <= range[1];
}
/**
* @param pm in µg/m³
*/
export function getAQIFromPM(pm: number): { aqiValue: number, aqiRange: AQIRange } {
pm = Math.ceil(pm);
let range = AQI_RANGES.find(range => isInRange(pm, range.pmRange));
if (!range)
range = AQI_RANGES.reduce((range, acc) =>
range.aqiRange[0] >= acc.aqiRange[0] ? range : acc
);
const [aqiLo, aqiHi] = range.aqiRange;
const [pmLo, pmHi] = range.pmRange;
const slope = (aqiHi - aqiLo) / (pmHi - pmLo);
return {aqiValue: Math.ceil(slope * (pm - pmLo) + aqiLo), aqiRange: range};
}
import {Theme, Tooltip, withStyles} from "@material-ui/core";
export const HtmlTooltip = withStyles((theme: Theme) => ({
tooltip: {
backgroundColor: '#f5f5f9',
color: 'rgba(0, 0, 0, 0.87)',
maxWidth: 400,
fontSize: theme.typography.pxToRem(12),
border: '1px solid #dadde9',
},
}))(Tooltip);
import {ST_WITHIN_FILTER_REVERSED} from "./CommonConfig";
const EQUALS = encodeURIComponent("=");
const SEMICOLON = encodeURIComponent(";");
const FROST_SERVER_URL = "http://193.196.138.56/frost-luftdata-api/v1.1";
const FEATURES_OF_INTEREST_URL = `${FROST_SERVER_URL}/FeaturesOfInterest`;
const GEO_JSON_DATA_URL = `${FEATURES_OF_INTEREST_URL}?$top=100&$resultFormat=GeoJSON`;
const OBSERVATIONS_BASE_QUERY = `$orderBy${EQUALS}phenomenonTime+DESC${SEMICOLON}` +
`$filter=Datastream/ObservedProperty/name eq 'P2'`;
export function getPMGeoJsonUrlWithLastObservation(
fromDate?: Date,
toDate?: Date,
fromValue?: number,
toValue?: number,
limitObservations?: number,
) {
const conditions: string[] = [];
if (fromValue) {
conditions.push(`result ge ${fromValue}`);
}
if (toValue) {
conditions.push(`result le ${toValue}`);
}
if (fromDate) {
conditions.push(`phenomenonTime ge ${fromDate.toISOString()}`);
}
if (toDate) {
conditions.push(`phenomenonTime le ${toDate.toISOString()}`);
}
const geographicFilters: string[] = [ST_WITHIN_FILTER_REVERSED];
let filterQuery = "", observationsFilterQuery = "";
if (conditions.length || geographicFilters.length) {
filterQuery = "&$filter=" + conditions
.map(condition => `Observations/${condition}`)
.concat(geographicFilters)
.join(" and ");
}
if (conditions.length) {
observationsFilterQuery = `${SEMICOLON}$filter${EQUALS}${conditions.join(" and ")}`;
}
const limitObservationsQuery = `${SEMICOLON}$top${EQUALS}${limitObservations || 1}`;
return `${GEO_JSON_DATA_URL}${filterQuery}` +
`&$expand=Observations(${OBSERVATIONS_BASE_QUERY}${limitObservationsQuery}${observationsFilterQuery})`;
}
export function getPMHistoryUrl(
id: number,
fromDate: Date,
toDate: Date,
limitObservations?: number,
) {
const conditions: string[] = [];
conditions.push(`phenomenonTime ge ${fromDate.toISOString()}`);
conditions.push(`phenomenonTime le ${toDate.toISOString()}`);
let filter = "";
if (conditions.length) {
filter = "$filter=" + conditions
.map(condition => `${condition}`)
.join(" and ");
}
const orderBy = "$orderBy=phenomenonTime";
const limit = `$top=${limitObservations || 100}`;
const select = "$select=phenomenonTime,result";
const queryParams = [select, filter, orderBy, limit].join("&");
return `${FEATURES_OF_INTEREST_URL}(${id})/Observations?${queryParams}`;
}
import {arrayAverage, kgCo2PerDayFactorFromSpeed, Dictionary, pmFactorFromSpeed} from "./Helpers";
import {Cesium3DTile, Cesium3DTileFeature, Ellipsoid, Entity as CesiumEntity, Math as CesiumMath} from "cesium";
type LonLat = {
longitude: number;
latitude: number;
}
export type TrafficCacheInfo = {
description: string;
points: LonLat[]
midPoint: LonLat;
/**
* The point 200 meters below and 200 meters to the left of the midpoint
*/
bottomLeft200: LonLat;
/**
* The point 200 meters above and 200 meters to the right of the midpoint
*/
topRight200: LonLat;
speed: number;
pmFactor: number;
jamFactor: number;
co2Factor: number;
quality: number;
segmentLength: number; // In Kilometers
};
export type TileFeaturesCacheInfo = {
parentId: string;
longitude: number;
latitude: number;
isCloseToStreet?: boolean;
dirty: boolean;
};
const FEATURE_ID_PROPERTY_NAME = "gml_id";
const FEATURE_PARENT_ID_PROPERTY_NAME = "gml_parent_id";
const TRAFFIC_DESCRIPTION_KEY = "description";
const TRAFFIC_SPEED_KEY = "speed";
const TRAFFIC_JAM_FACTOR_KEY = "jamFactor";
const TRAFFIC_QUALITY_KEY = "quality";
const TRAFFIC_LENGTH_KEY = "length";
function getFeatureId(feature: Cesium3DTileFeature): string | undefined {
return feature.getProperty && feature.getProperty(FEATURE_ID_PROPERTY_NAME);
}
function getFeatureParentId(feature: Cesium3DTileFeature): string | undefined {
return feature.getProperty && feature.getProperty(FEATURE_PARENT_ID_PROPERTY_NAME);
}
export class TrafficCacheManager {
trafficCache: Dictionary<TrafficCacheInfo> = {};
tileFeaturesCache: Dictionary<TileFeaturesCacheInfo> = {};
getIsCloseToStreet(feature: Cesium3DTileFeature): boolean | undefined {
const tileInfo = this.getTilesInfo(feature);
return tileInfo?.dirty ? undefined : tileInfo?.isCloseToStreet;
}
hasTrafficInfo(): boolean {
return !!Object.keys(this.trafficCache).length;
}
recalculateTrafficCache(cesiumEntities?: CesiumEntity[]) {
this.trafficCache = {};
cesiumEntities?.forEach(entity => {
if (entity.properties) {
const twoHundredMetersInDegrees = 200 / 111000;
// @ts-ignore
const locations = Ellipsoid.WGS84.cartesianArrayToCartographicArray(entity.polyline.positions._value),
points = locations.map(p => ({longitude: CesiumMath.toDegrees(p.longitude), latitude: CesiumMath.toDegrees(p.latitude)})),
longitude = arrayAverage(points.map(p => p.longitude)),
latitude = arrayAverage(points.map(p => p.latitude)),
bottomLeft200: LonLat = {longitude: longitude - twoHundredMetersInDegrees, latitude: latitude - twoHundredMetersInDegrees},
topRight200: LonLat = {longitude: longitude + twoHundredMetersInDegrees, latitude: latitude + twoHundredMetersInDegrees},
description = entity.properties[TRAFFIC_DESCRIPTION_KEY].toString(),
speed = +entity.properties[TRAFFIC_SPEED_KEY],
jamFactor = +entity.properties[TRAFFIC_JAM_FACTOR_KEY],
quality = +entity.properties[TRAFFIC_QUALITY_KEY],
segmentLength = +entity.properties[TRAFFIC_LENGTH_KEY],
co2Factor = kgCo2PerDayFactorFromSpeed(speed, segmentLength),
pmFactor = pmFactorFromSpeed(speed, segmentLength);
this.trafficCache[entity.name!] = {
description,
points,
midPoint: {longitude, latitude},
bottomLeft200,
topRight200,
speed,
jamFactor,
co2Factor,
quality,
segmentLength,
pmFactor,
};
}
});
}
setTileInfo(tile: Cesium3DTile) {
for (let i = 0; i < tile.content.featuresLength; i++) {
const feature = tile.content.getFeature(i);
this.setInitialFeatureInfo(feature);
}
}
setInitialFeatureInfo(feature: Cesium3DTileFeature) {
const longitude = feature.getProperty("longtitude") || feature.getProperty("longitude");
const latitude = feature.getProperty("latitude");
if (!longitude || !latitude)
return;
this.tileFeaturesCache[getFeatureId(feature)!] = {
parentId: feature.getProperty(FEATURE_PARENT_ID_PROPERTY_NAME),
longitude: +longitude,
latitude: +latitude,
dirty: true
};
}
async makeAllFeaturesDirty() {
Object.values(this.tileFeaturesCache)
.forEach((feature) => {
feature.dirty = true;
});
}
async recalculateFactorsForDirtyFeatures() {
return Promise.all(
Object.values(this.tileFeaturesCache)
.filter((feature) => feature.dirty)
.map(async (feature) => {
const parent = this.getTilesInfoByd(feature.parentId);
if (parent && !parent.dirty) {
feature.isCloseToStreet = parent.isCloseToStreet;
} else {
feature.isCloseToStreet = this.isCloserThan200MetersToAStreet(feature.longitude, feature.latitude);
}
feature.dirty = false;
})
).then(() => {});
}
private isCloserThan200MetersToAStreet(featureLongitude: number, featureLatitude: number) {
// const distanceToFeature = (lon: number, lat: number) =>
// distanceSquaredInMeters(lon, lat, featureLongitude, featureLatitude);
for (const t of Object.values(this.trafficCache)) {
if (featureLongitude <= t.topRight200.longitude &&
featureLatitude <= t.topRight200.latitude &&
featureLongitude >= t.bottomLeft200.longitude &&
featureLatitude >= t.bottomLeft200.latitude)
return true;
// const d = distanceToFeature(t.midPoint.longitude, t.midPoint.latitude);
// if (d <= 40000) {
// return true;
// }
}
return false;
}
getTilesInfo(feature: Cesium3DTileFeature): TileFeaturesCacheInfo | undefined {
if (!feature.getProperty) // not a feature
return undefined;
const id = getFeatureId(feature);
const parentId = getFeatureParentId(feature);
const tilesInfo = this.getTilesInfoByd(parentId) || this.getTilesInfoByd(id);
if (tilesInfo)
return tilesInfo;
this.setInitialFeatureInfo(feature);
return this.getTilesInfoByd(id);
}
private getTilesInfoByd(featureId?: string): TileFeaturesCacheInfo | undefined {
return featureId ? this.tileFeaturesCache[featureId] : undefined;
}
}
import {ST_WITHIN_FILTER} from "./CommonConfig";
import {encodeFrostId} from "./Helpers";
const FROST_SERVER_URL = "http://192.168.178.27:8080/FROST-Server/v1.1";
// const FROST_SERVER_URL = "http://localhost:8080/FROST-Server/v1.1";
const FEATURES_OF_INTEREST_URL = `${FROST_SERVER_URL}/FeaturesOfInterest`;
const GEO_JSON_DATA_URL = `${FEATURES_OF_INTEREST_URL}?$top=1000&$resultFormat=GeoJSON`;
export function getTrafficGeoJsonUrlForFeaturesWithinBBox(): string {
let filterQuery = `&$filter=${ST_WITHIN_FILTER}`;
return `${GEO_JSON_DATA_URL}${filterQuery}`;
}
export function getObservationUrlForFeatureOfInterest(
featureOfInterestId: string,
fromDate?: Date,
toDate?: Date,
fromSpeed?: number,
toSpeed?: number,
fromJamFactor?: number,
toJamFactor?: number,
limitObservations?: number,
skipObservations?: number,
): string {
const conditions: string[] = [];
if (fromSpeed) {
conditions.push(`result ge ${fromSpeed}`);
}
if (toSpeed) {
conditions.push(`result le ${toSpeed}`);
}
if (fromDate) {
conditions.push(`phenomenonTime ge ${fromDate.toISOString()}`);
}
if (toDate) {
conditions.push(`phenomenonTime le ${toDate.toISOString()}`);
}
if (fromJamFactor) {
conditions.push(`parameters/jamFactor ge ${fromJamFactor}`);
}
if (toJamFactor) {
conditions.push(`parameters/jamFactor le ${toJamFactor}`);
}
let filter = "";
if (conditions.length) {
filter = "$filter=" + conditions.join(" and ");
}
const orderBy = "$orderBy=phenomenonTime+DESC";
const encodedId = encodeFrostId(featureOfInterestId);
const limit = `$top=${limitObservations || 1}`;
const skip = skipObservations ? `$skip=${skipObservations}` : "";
const select = "$select=result,parameters,resultQuality";
const queryParams = [select, filter, orderBy, limit, skip].filter(it => it !== "").join("&");
return `${FEATURES_OF_INTEREST_URL}('${encodedId}')/Observations?${queryParams}`.replaceAll(" ", "+");
}
export function getTrafficHistoryUrl(
id: string,
fromDate: Date,
toDate: Date,
limitObservations?: number,
) {
const conditions: string[] = [];
conditions.push(`phenomenonTime ge ${fromDate.toISOString()}`);
conditions.push(`phenomenonTime le ${toDate.toISOString()}`);
let filter = "";
if (conditions.length) {
filter = "$filter=" + conditions
.map(condition => `${condition}`)
.join(" and ");
}
const orderBy = "$orderBy=phenomenonTime";
const encodedId = encodeFrostId(id);
const limit = `$top=${limitObservations || 100}`;
const select = "$select=phenomenonTime,result,parameters";
const queryParams = [select, filter, orderBy, limit].join("&");
return `${FEATURES_OF_INTEREST_URL}('${encodedId}')/Observations?${queryParams}`;
}
import React from "react";
import {Cesium3DTileset, CesiumComponentRef, CesiumMovementEvent, GeoJsonDataSource, Viewer} from "resium";
import Cesium, {
Camera, Cesium3DTile,
Cesium3DTileFeature,
Cesium3DTileStyle,
Color, ColorMaterialProperty, EllipseGraphics, HeadingPitchRange, JulianDate, MaterialProperty,
Rectangle, ScreenSpaceEventHandler, ScreenSpaceEventType
} from "cesium";
import {Link} from "ogc3dcontainerentitiesmodel";
import {AQIRange, Dictionary, getAQIFromPM, randomString} from "../common/Helpers";
import {TrafficCacheManager} from "../common/TrafficCacheManager";
import {
trafficCo2ColorGradient,
ColorGradient,
heatingColorGradient,
jamFactorColorGradient, pmSensorColorGradient,
pmTrafficColorGradient,
speedColorGradient, speedStrategyColorGradient, DEFAULT_BUILDINGS_COLOR_CSS, aqiSensorColorGradient
} from "../common/ColorGradient";
import {FeatureCollection} from "geojson";
import {Color as AlertSeverity} from "@material-ui/lab/Alert";
import {Alert} from "@material-ui/lab";
import {Divider, Snackbar, Typography} from "@material-ui/core";
import {Popup, PopupDataInfo} from "./popups/Popup";
import {addHours, subHours} from "date-fns";
export type HeatingInfo = {
spaceHeating: number;
domesticWaterHeating: number;
electricalAppliancesHeating: number;
}
export type HeatingInfoDictionary = Dictionary<HeatingInfo>
export type TilesetInfo = {
id: string;
link: Link;
visible?: boolean;
}
export type TrafficVisibilityMode = "speed" | "co2" | "jam" | "pm";
export type DateFilter = { fromDate?: Date; toDate?: Date };
export type TrafficInfo = {
visibilityMode?: TrafficVisibilityMode;
colorSpeedByStrategy: boolean;
colorPMByAqi: boolean;
trafficGeoJson?: FeatureCollection;
isTrafficGeoJsonFiltered: boolean;
pmGeoJson?: FeatureCollection;
isPMGeoJsonFiltered: boolean;
dateFilter: DateFilter;
}
type MapState = {
tilesets: TilesetInfo[];
trafficInfo: TrafficInfo;
flownHome: boolean;
loadingIds: string[];
heatingInfo?: HeatingInfoDictionary;
snackbar: {
message?: string;
severity?: AlertSeverity;
shown: boolean;
},
popupInfo?: PopupDataInfo;
}
type MapProps = {}
Camera.DEFAULT_VIEW_RECTANGLE = Rectangle.fromDegrees(9.170776511568553, 48.780659153207395, 9.211500122636073, 48.803598100631824);
Camera.DEFAULT_VIEW_FACTOR = 0.0005;
Camera.DEFAULT_OFFSET = new HeadingPitchRange(0, -0.5, 0);
const DEFAULT_BUILDINGS_CESIUM_COLOR = Color.fromCssColorString(DEFAULT_BUILDINGS_COLOR_CSS);
export class Map extends React.Component<MapProps, MapState> {
private cesium3dTilesetDictionary: Dictionary<Cesium.Cesium3DTileset> = {};
private cesiumViewer?: Cesium.Viewer;
private trafficCacheManager = new TrafficCacheManager();
trafficJsonDataSource?: Cesium.GeoJsonDataSource;
pmJsonDataSource?: Cesium.GeoJsonDataSource;
constructor(props: any) {
super(props);
this.state = {
tilesets: [],
trafficInfo: {
visibilityMode: undefined,
colorSpeedByStrategy: false,
colorPMByAqi: false,
isTrafficGeoJsonFiltered: false,
isPMGeoJsonFiltered: false,
dateFilter: {},
},
flownHome: false,
loadingIds: [],
snackbar: {
shown: false
},
};
fetch("/assets/stockach_heating.csv")
.then(data => data.text())
.then(csv => {
const lines = csv.split("\n")
.map(line => line.split(",").map(cell => cell.toString().trim()));
const heatingInfo: HeatingInfoDictionary = {};
lines.slice(1).forEach(
line => {
const gmlId = line[0];
const parentGmlId = line[1];
const spaceHeating = +line[16] / 365;
const domesticWaterHeating = +line[17] / 365;
const electricalAppliancesHeating = +line[18] / 365;
heatingInfo[gmlId] = heatingInfo[parentGmlId] = {
spaceHeating,
domesticWaterHeating,
electricalAppliancesHeating
};
}
);
this.setState({
heatingInfo
});
}).then(() => this.makeAllCesium3dStylesDirty());
}
componentDidUpdate(prevProps: Readonly<MapProps>, prevState: Readonly<MapState>, snapshot?: any) {
if (prevState.trafficInfo.colorSpeedByStrategy !== this.state.trafficInfo.colorSpeedByStrategy) {
this.colorTrafficLayer();
}
if (prevState.trafficInfo.colorPMByAqi !== this.state.trafficInfo.colorPMByAqi) {
this.colorPMLayer();
}
}
flyToTileset(tilesetId: string) {
const cesium3DTileset = this.cesium3dTilesetDictionary[tilesetId];
if (this.cesiumViewer && cesium3DTileset) {
this.cesiumViewer.flyTo(cesium3DTileset).then(() => {});
}
}
showTileset(tileset: TilesetInfo): void {
tileset.visible = true;
let existingTileset = this.state.tilesets.find(t => t.id === tileset.id);
if (!existingTileset) {
this.setState(state => ({
tilesets: [...state.tilesets, tileset]
}));
} else if (!existingTileset.visible) {
existingTileset = {...existingTileset, visible: true};
const otherTilesets = this.state.tilesets.filter(t => t.id !== tileset.id);
this.setState({
tilesets: [...otherTilesets, existingTileset]
});
}
}
hideTileset(id: string): void {
const tileset = this.state.tilesets.find(t => t.id === id);
const otherTilesets = this.state.tilesets.filter(t => t.id !== id);
if (tileset) {
const visibleTileset = {...tileset, visible: false};
this.setState({
tilesets: [...otherTilesets, visibleTileset]
});
}
}
showTrafficLayer(geoJson: FeatureCollection, visibilityMode: TrafficVisibilityMode, dateFilter: DateFilter, isFiltered: boolean) {
this.setState(state => (
{
trafficInfo: {
...state.trafficInfo,
visibilityMode: visibilityMode,
trafficGeoJson: geoJson,
isTrafficGeoJsonFiltered: isFiltered,
dateFilter: dateFilter,
}
}
));
this.colorTrafficLayer(visibilityMode);
this.makeAllCesium3dStylesDirty();
this.addCustomLoadingId("show-traffic-layer");
}
showPMLayer(geoJson: FeatureCollection, visibilityMode: TrafficVisibilityMode, dateFilter: DateFilter, isFiltered: boolean) {
this.setState(state => (
{
trafficInfo: {
...state.trafficInfo,
visibilityMode: visibilityMode,
pmGeoJson: geoJson,
isPMGeoJsonFiltered: isFiltered,
dateFilter: dateFilter,
}
}
));
this.colorTrafficLayer(visibilityMode);
this.makeAllCesium3dStylesDirty();
this.addCustomLoadingId("show-pm-layer");
}
hideTrafficLayer() {
if (this.state.trafficInfo.visibilityMode) {
this.setState(state => (
{
trafficInfo: {
...state.trafficInfo,
visibilityMode: undefined,
trafficGeoJson: undefined,
pmGeoJson: undefined,
dateFilter: {},
}
}
));
this.makeAllCesium3dStylesDirty();
}
}
onColorByStrategyChanged(newValue: boolean) {
this.setState(state => ({
trafficInfo: {
...state.trafficInfo,
colorSpeedByStrategy: newValue
}
}));
}
onColorByAqiChanged(newValue: boolean) {
this.setState(state => ({
trafficInfo: {
...state.trafficInfo,
colorPMByAqi: newValue
}
}));
}
getVisibleItemsIds(): string[] {
return this.state.tilesets.map(t => t.id);
}
handleCesiumViewerRef(ref: CesiumComponentRef<Cesium.Viewer> | null) {
if (ref?.cesiumElement && !this.cesiumViewer) {
this.cesiumViewer = ref.cesiumElement;
this.setMouseClickEvent();
// Set Timeline Clock
const d = new Date();
d.setHours(10);
d.setMinutes(0);
this.cesiumViewer.clock.currentTime = JulianDate.fromDate(d);
}
}
_pickedEntityData?: {
entity?: any;
material?: MaterialProperty;
};
get pickedEntityData() {return this._pickedEntityData?.entity;}
set pickedEntityData(newEntity) {
const oldData = this._pickedEntityData;
if (oldData?.entity === newEntity) {
return;
}
if (oldData && oldData.entity && oldData.material) {
if (oldData.entity.polyline)
oldData.entity.polyline.material = oldData.material;
else if (oldData.entity.ellipse)
oldData.entity.ellipse.material = oldData.material;
else if (oldData.entity.color) {
oldData.entity.color = oldData.material;
}
}
let material;
if (newEntity) {
if (newEntity.polyline) {
material = newEntity.polyline.material;
} else if (newEntity.ellipse) {
material = newEntity.ellipse.material;
} else if (newEntity.color) {
material = (newEntity.color as Color).clone();
newEntity.color = Color.VIOLET;
}
}
this.setPopupInfo(newEntity);
this._pickedEntityData = {
entity: newEntity,
material: material
};
this.colorTrafficLayer();
this.colorPMLayer();
}
setPopupInfo(entity?: any) {
if (entity?.polyline) {
const dateFilter = this.state.trafficInfo.dateFilter;
if (dateFilter.toDate) {
dateFilter.fromDate = subHours(dateFilter.toDate, 24);
} else if (dateFilter.fromDate) {
dateFilter.toDate = addHours(dateFilter.fromDate, 24);
}
this.setState({
popupInfo: {
trafficVisibilityMode: this.state.trafficInfo.visibilityMode,
type: "traffic",
trafficInfo: {
id: entity.name,
...this.trafficCacheManager.trafficCache[entity.name]
},
dateFilter: dateFilter,
isFiltered: this.state.trafficInfo.isTrafficGeoJsonFiltered
}
});
} else if (entity?.ellipse) {
const aqi: { aqiValue: number, aqiRange: AQIRange } = entity.properties.aqi._value;
console.log(aqi);
this.setState({
popupInfo: {
trafficVisibilityMode: this.state.trafficInfo.visibilityMode,
type: "pm",
pmInfo: {
id: entity.properties.id.toString(),
description: entity.description.toString(),
pm: +entity.properties.pm,
aqiValue: aqi.aqiValue,
aqiRange: aqi.aqiRange,
longitude: +entity.properties.longitude,
latitude: +entity.properties.latitude,
},
dateFilter: this.state.trafficInfo.dateFilter,
isFiltered: this.state.trafficInfo.isPMGeoJsonFiltered
}
});
} else if (entity?.color) {
const feature = entity as Cesium3DTileFeature;
this.setState({
popupInfo: {
trafficVisibilityMode: this.state.trafficInfo.visibilityMode,
type: "building",
buildingInfo: {
id: feature.getProperty("gml_id"),
heatingInfo: this.getHeatingByCesium3DTileFeature(feature),
properties: feature.getPropertyNames().reduce((prev, curr) =>
({...prev, [curr]: feature.getProperty(curr)}), {})
},
dateFilter: this.state.trafficInfo.dateFilter,
isFiltered: false,
}
});
} else {
this.setState({
popupInfo: undefined
});
}
}
private closePopup() {
this.setState({popupInfo: undefined});
this.pickedEntityData = null;
}
setMouseClickEvent() {
const handler = new ScreenSpaceEventHandler(this.cesiumViewer?.scene.canvas);
const action = (movement: CesiumMovementEvent) => {
if (!movement?.position)
return;
const feature = this.cesiumViewer?.scene.pick(movement.position);
this.pickedEntityData = feature?.id || (feature?.getProperty ? feature : undefined);
};
handler.setInputAction(action, ScreenSpaceEventType.LEFT_CLICK);
this.cesiumViewer?.screenSpaceEventHandler.setInputAction(action, ScreenSpaceEventType.LEFT_CLICK);
}
isLoading() {
return !!this.state.loadingIds.length;
}
handleTilesetReady(tilesetId: string, cesium3dTileset: Cesium.Cesium3DTileset) {
this.cesium3dTilesetDictionary[tilesetId] = cesium3dTileset;
if (!this.state.flownHome) {
this.flyToTileset(tilesetId);
this.setState({flownHome: true});
}
this.setTilesetStyle(cesium3dTileset);
const loadingId = this.addNewLoadingId();
cesium3dTileset.tileLoad.addEventListener((tile: Cesium3DTile) => {
this.trafficCacheManager.setTileInfo(tile);
});
const cleanLoadingIds = () => this.removeLoadingId(loadingId);
cesium3dTileset.allTilesLoaded.addEventListener(() => {
const mode = this.state.trafficInfo.visibilityMode;
const filtered = this.state.trafficInfo.isTrafficGeoJsonFiltered;
if (filtered && mode && ["speed", "jam"].includes(mode)) {
this.trafficCacheManager.recalculateFactorsForDirtyFeatures()
.then(() => cesium3dTileset.makeStyleDirty())
.then(cleanLoadingIds);
} else {
cleanLoadingIds();
}
});
}
evaluateShowAccordingToStreetDistance(feature: Cesium3DTileFeature): boolean {
return this.trafficCacheManager.getIsCloseToStreet(feature) === true; // will not show if false or undefined (dirty)
}
evaluateColorAccordingToHeating(feature: Cesium3DTileFeature, result?: Color): Color {
if (!this.state.heatingInfo)
return Color.clone(DEFAULT_BUILDINGS_CESIUM_COLOR, result);
let heating = undefined;
const heatingInfo = this.getHeatingByCesium3DTileFeature(feature);
if (heatingInfo) {
heating = heatingInfo.spaceHeating + heatingInfo.domesticWaterHeating + heatingInfo.electricalAppliancesHeating;
}
return heatingColorGradient.getColorAtAsCesiumColor(heating);
}
getHeatingByCesium3DTileFeature(feature: Cesium3DTileFeature): HeatingInfo | undefined {
if (!this.state.heatingInfo)
return undefined;
let features = [
"gml_id",
"gml_parent_id",
"gmlIdALKISLageBezeichnung_1",
"gmlIdALKISLageBezeichnung_2",
].map(property => feature.getProperty(property));
const id = features.find(id => this.state.heatingInfo!.hasOwnProperty(id));
if (id) {
return this.state.heatingInfo[id];
}
return undefined;
}
setTilesetStyle(cesium3dTileset: Cesium.Cesium3DTileset) {
let style = new Cesium3DTileStyle();
// @ts-ignore
style.color = {
evaluateColor: (feature, result) => {
switch (this.state.trafficInfo.visibilityMode) {
case "co2":
return this.evaluateColorAccordingToHeating(feature, result);
default:
return DEFAULT_BUILDINGS_CESIUM_COLOR;
}
}
};
// @ts-ignore
style.show = {
evaluate: (feature: Cesium3DTileFeature, _?: boolean): boolean => {
if (!this.state.trafficInfo.isTrafficGeoJsonFiltered)
return true;
switch (this.state.trafficInfo.visibilityMode) {
case "speed":
case "jam":
return this.evaluateShowAccordingToStreetDistance(feature);
default:
return true;
}
}
};
cesium3dTileset.style = style;
}
async refreshTilesetsStyles() {
const loadingId = this.addNewLoadingId();
return this.trafficCacheManager.makeAllFeaturesDirty()
.then(() => this.trafficCacheManager.recalculateFactorsForDirtyFeatures())
.then(() => this.makeAllCesium3dStylesDirty())
.then(() => this.removeLoadingId(loadingId));
}
makeAllCesium3dStylesDirty() {
Object.values(this.cesium3dTilesetDictionary)
.forEach(cesium3dTileset => cesium3dTileset.makeStyleDirty());
}
updateTrafficCache() {
this.trafficCacheManager.recalculateTrafficCache(this.trafficJsonDataSource?.entities.values);
}
colorTrafficLayer(trafficVisibilityMode?: TrafficVisibilityMode) {
const loadingId = this.addNewLoadingId();
if (!trafficVisibilityMode)
trafficVisibilityMode = this.state.trafficInfo.visibilityMode;
this.trafficJsonDataSource?.entities.values.forEach(entity => {
try {
if (entity?.polyline) {
const id = entity.name!;
let color;
if (this.pickedEntityData?.name === id)
color = Color.VIOLET;
else switch (trafficVisibilityMode) {
case "speed":
const speed = this.trafficCacheManager.trafficCache[id]?.speed;
if (this.state.trafficInfo.colorSpeedByStrategy) {
color = speedStrategyColorGradient.getColorAtAsCesiumColor(speed);
} else {
color = speedColorGradient.getColorAtAsCesiumColor(speed);
}
break;
case "jam":
const jamFactor = this.trafficCacheManager.trafficCache[id]?.jamFactor;
color = jamFactorColorGradient.getColorAtAsCesiumColor(jamFactor);
break;
case "co2":
const co2 = this.trafficCacheManager.trafficCache[id]?.co2Factor;
color = trafficCo2ColorGradient.getColorAtAsCesiumColor(co2);
break;
case "pm":
const pm = this.trafficCacheManager.trafficCache[id]?.pmFactor;
color = pmTrafficColorGradient.getColorAtAsCesiumColor(pm);
break;
default:
color = Color.VIOLET;
}
entity.polyline.material = new ColorMaterialProperty(color);
}
} catch (e) {
console.error(e);
}
});
this.removeLoadingId(loadingId);
}
colorPMLayer(trafficVisibilityMode?: TrafficVisibilityMode) {
if (!trafficVisibilityMode) {
trafficVisibilityMode = this.state.trafficInfo.visibilityMode;
}
if (trafficVisibilityMode !== "pm") {
return;
}
this.pmJsonDataSource?.entities.values.forEach(entity => {
try {
const pm = entity.properties!["pm"];
let color;
if (this.pickedEntityData?.name === entity.name)
color = Color.VIOLET.withAlpha(0.7);
else {
if (this.state.trafficInfo.colorPMByAqi) {
const aqi = getAQIFromPM(pm);
color = aqiSensorColorGradient.getColorAtAsCesiumColor(aqi.aqiValue).withAlpha(0.5);
} else {
color = pmSensorColorGradient.getColorAtAsCesiumColor(pm).withAlpha(0.5);
}
}
entity.ellipse = new EllipseGraphics({
semiMinorAxis: 100,
semiMajorAxis: 100,
material: color
});
entity.billboard = undefined;
} catch (e) {
console.error(e);
}
});
}
private handleSnackbarClose() {
this.setState(state => ({
snackbar: {
...state.snackbar,
shown: false
}
}));
}
showSnackbar(message: string, severity: AlertSeverity = "info") {
this.setState({
snackbar: {
message,
severity,
shown: true
}
});
}
private handleTrafficGeoJsonDataSourceLoaded(geoJsonDataSource: Cesium.GeoJsonDataSource) {
const loadingId = this.addNewLoadingId(5);
this.trafficJsonDataSource = geoJsonDataSource;
this.updateTrafficCache();
this.colorTrafficLayer();
const cleanLoadingIdsAndShowSnackbar = () => {
this.removeLoadingId(loadingId, 5);
this.removeLoadingId("show-traffic-layer", 5);
const featureCount = this.state.trafficInfo.trafficGeoJson?.features.length;
if (featureCount) {
this.showSnackbar(`${featureCount} traffic features loaded.`, "success");
} else {
this.showSnackbar("Couldn't find any traffic features matching the query!", "info");
}
};
const mode = this.state.trafficInfo.visibilityMode;
const filtered = this.state.trafficInfo.isTrafficGeoJsonFiltered;
if (filtered && mode && ["speed", "jam"].includes(mode))
this.refreshTilesetsStyles().then(cleanLoadingIdsAndShowSnackbar);
else
cleanLoadingIdsAndShowSnackbar();
}
private handlePMGeoJsonDataSourceLoaded(geoJsonDataSource: Cesium.GeoJsonDataSource) {
const loadingId = this.addNewLoadingId(5);
this.pmJsonDataSource = geoJsonDataSource;
this.colorPMLayer();
const cleanLoadingIdsAndShowSnackbar = () => {
this.removeLoadingId(loadingId, 5);
this.removeLoadingId("show-pm-layer", 5);
const featureCount = this.state.trafficInfo.pmGeoJson?.features.length;
if (featureCount) {
this.showSnackbar(`${featureCount} PM features loaded.`, "success");
} else {
this.showSnackbar("Couldn't find any PM features matching the query!", "info");
}
};
cleanLoadingIdsAndShowSnackbar();
}
addNewLoadingId(delay?: number): string {
const loadingId = randomString(50);
return this.addCustomLoadingId(loadingId, delay);
}
addCustomLoadingId(loadingId: string, delay?: number): string {
const lambda = () => this.setState(state => ({
loadingIds: [...state.loadingIds, loadingId]
}));
if (delay) {
setTimeout(lambda, delay);
} else {
lambda();
}
return loadingId;
}
removeLoadingId(loadingId: string, delay?: number) {
const lambda = () => this.setState(state => ({
loadingIds: state.loadingIds.filter(id => id !== loadingId)
}));
if (delay) {
setTimeout(lambda, delay);
} else {
lambda();
}
}
private legendGradients(): ColorGradient[] {
switch (this.state.trafficInfo.visibilityMode) {
case "speed":
return this.state.trafficInfo.colorSpeedByStrategy ? [speedStrategyColorGradient] : [speedColorGradient];
case "jam":
return [jamFactorColorGradient];
case "co2":
if (this.state.tilesets.some(t => t.visible))
return [trafficCo2ColorGradient, heatingColorGradient];
return [trafficCo2ColorGradient];
case "pm":
if (this.state.trafficInfo.colorPMByAqi)
return [pmTrafficColorGradient, aqiSensorColorGradient];
return [pmTrafficColorGradient, pmSensorColorGradient];
default:
return [];
}
}
render() {
return (<Viewer
style={{width: "100%", height: "100%", cursor: this.isLoading() ? "progress" : "default"}}
ref={this.handleCesiumViewerRef.bind(this)}
timeline={false}
animation={false}
>
{this.state.popupInfo ? <Popup info={this.state.popupInfo} handleClose={this.closePopup.bind(this)}/> : null}
<div style={{
position: "absolute",
right: 50,
bottom: 50,
background: "#fffffff2",
padding: 20,
paddingBottom: 10,
borderRadius: 10,
display: this.legendGradients().length ? "unset" : "none"
}}>
<Typography>Legend</Typography>
<Divider/>
<div style={{marginTop: 10}}>
{this.legendGradients().map(g => g.getLegendComponent())}
</div>
</div>
<Snackbar
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
open={this.state.snackbar.shown}
autoHideDuration={3000}
onClose={this.handleSnackbarClose.bind(this)}
message={this.state.snackbar.message}
>
<Alert severity={this.state.snackbar.severity} onClose={this.handleSnackbarClose.bind(this)}>
{this.state.snackbar.message}
</Alert>
</Snackbar>
{this.state.tilesets.map(tileset => (
<Cesium3DTileset
key={tileset.id}
url={tileset.link.href}
show={tileset.visible}
onReady={cesium3dTileset => this.handleTilesetReady(tileset.id, cesium3dTileset)}
onLoadProgress={(numberOfPendingRequests, numberOfTilesProcessing) => {
const loadingId = "tileset-load-progress";
if (numberOfPendingRequests + numberOfTilesProcessing === 0) {
this.removeLoadingId(loadingId);
} else {
this.addCustomLoadingId(loadingId);
}
}}
/>))}
{this.state.trafficInfo.trafficGeoJson && this.state.trafficInfo.visibilityMode ? <GeoJsonDataSource
key={`geo-json-${this.state.trafficInfo.visibilityMode}`}
data={this.state.trafficInfo.trafficGeoJson}
clampToGround={true}
fill={Color.GRAY}
stroke={Color.GRAY}
strokeWidth={3}
onLoad={(geoJsonDataSource) => {
this.handleTrafficGeoJsonDataSourceLoaded(geoJsonDataSource);
}}
/> : null}
{this.state.trafficInfo.pmGeoJson && this.state.trafficInfo.visibilityMode === "pm" ?
<GeoJsonDataSource
key={`geo-json-pm-${this.state.trafficInfo.visibilityMode}`}
data={this.state.trafficInfo.pmGeoJson}
clampToGround={true}
onLoad={(geoJsonDataSource) => {
this.handlePMGeoJsonDataSourceLoaded(geoJsonDataSource);
}}
/> : null}
</Viewer>);
}
}
import React from "react";
import {
createStyles,
CssBaseline,
Divider,
Drawer,
Fab,
IconButton,
Theme,
Tooltip, withStyles
} from "@material-ui/core";
import clsx from "clsx";
import {Map} from "./Map";
import MenuIcon from "@material-ui/icons/Menu";
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
import {OGC3DContainerLayer} from "./layers/OGC3DContainerLayer";
import {STALayer} from "./layers/STALayer";
const SIDEBAR_WIDTH = 300;
const styles = (theme: Theme) => createStyles({
root: {
display: "flex",
width: "100%"
},
menuButton: {
position: "absolute",
"z-index": 1000,
left: 30,
top: 14,
backgroundColor: "ghostwhite"
},
hide: {
display: "none",
},
drawer: {
width: SIDEBAR_WIDTH,
flexShrink: 0
},
drawerPaper: {
width: SIDEBAR_WIDTH,
backgroundColor: "#f6f6f6"
},
drawerHeader: {
display: "flex",
alignItems: "center",
padding: theme.spacing(0, 1),
// necessary for content to be below app bar
justifyContent: "flex-end",
},
content: {
flexGrow: 1,
backgroundColor: "#defff4",
transition: theme.transitions.create("margin", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
marginLeft: -SIDEBAR_WIDTH,
},
contentShift: {
transition: theme.transitions.create("margin", {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen
}),
marginLeft: 0
},
});
type SidebarProps = { classes: any }
type SidebarState = {
isOpen: boolean;
map: Map | null
};
class SidebarClass extends React.Component<SidebarProps, SidebarState> {
constructor(props: SidebarProps) {
super(props);
this.state = {
isOpen: false,
map: null
};
}
private handleDrawerToggle() {
this.setState(state => ({isOpen: !state.isOpen}));
}
render() {
return (<div className={this.props.classes.root}>
<CssBaseline/>
<Tooltip title="Menu">
<Fab
color="inherit"
size={"small"}
aria-label="open drawer"
onClick={this.handleDrawerToggle.bind(this)}
className={clsx(this.props.classes.menuButton, this.state.isOpen && this.props.classes.hide)}
>
<MenuIcon/>
</Fab>
</Tooltip>
<Drawer
className={this.props.classes.drawer}
classes={{
paper: this.props.classes.drawerPaper,
}}
variant={"persistent"}
anchor={"left"}
open={this.state.isOpen}
onClose={this.handleDrawerToggle.bind(this)}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
>
<div className={this.props.classes.drawerHeader}>
<IconButton onClick={this.handleDrawerToggle.bind(this)}>
<ChevronLeftIcon/>
</IconButton>
</div>
<Divider/>
{this.state.map ?
[
<OGC3DContainerLayer
key={"ogc-layer"}
onTilesetAdded={this.state.map.showTileset.bind(this.state.map)}
onTilesetRemoved={this.state.map.hideTileset.bind(this.state.map)}
getVisibleItemsIds={this.state.map.getVisibleItemsIds.bind(this.state.map)}
flyToTileset={this.state.map.flyToTileset.bind(this.state.map)}
/>,
<STALayer
key={"traffic-layer"}
onTrafficLayerAdded={this.state.map.showTrafficLayer.bind(this.state.map)}
onTrafficLayerRemoved={this.state.map.hideTrafficLayer.bind(this.state.map)}
onColorByStrategyChanged={this.state.map.onColorByStrategyChanged.bind(this.state.map)}
onColorByAqiChanged={this.state.map.onColorByAqiChanged.bind(this.state.map)}
onPMLayerAdded={this.state.map.showPMLayer.bind(this.state.map)}
addLoadingId={this.state.map.addCustomLoadingId.bind(this.state.map)}
removeLoadingId={this.state.map.removeLoadingId.bind(this.state.map)}
showSnackbar={this.state.map.showSnackbar.bind(this.state.map)}
/>
]
: null}
</Drawer>
<main
className={clsx(this.props.classes.content, {
[this.props.classes.contentShift]: this.state.isOpen
})}>
<Map ref={map => {
if (map && !this.state.map)
this.setState({map});
}}/>
</main>
</div>);
}
}
export const Sidebar
= withStyles(styles)(SidebarClass);
import {Accordion, AccordionSummary, withStyles} from "@material-ui/core";
export const StyledAccordion = withStyles({
root: {
'&$expanded': {
margin: 0,
},
},
expanded: {},
})(Accordion) as typeof Accordion;
export const StyledAccordionSummary = withStyles({
root: {
backgroundColor: 'rgba(0, 0, 0, .03)',
borderBottom: '1px solid rgba(0, 0, 0, .125)',
marginBottom: -1,
minHeight: 56,
'&$expanded': {
minHeight: 56,
},
},
content: {
'&$expanded': {
margin: '12px 0',
},
},
expanded: {},
})(AccordionSummary) as typeof AccordionSummary;
import {createStyles, fade, SvgIcon, SvgIconProps, Theme, withStyles} from "@material-ui/core";
import {TreeItem, TreeItemProps} from "@material-ui/lab";
export function MinusSquare(props: SvgIconProps) {
// noinspection LongLine
return (
<SvgIcon fontSize="inherit" style={{ width: 14, height: 14 }} {...props}>
{/* tslint:disable-next-line: max-line-length */}
<path d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z" />
</SvgIcon>
);
}
export function PlusSquare(props: SvgIconProps) {
// noinspection LongLine
return (
<SvgIcon fontSize="inherit" style={{ width: 14, height: 14 }} {...props}>
{/* tslint:disable-next-line: max-line-length */}
<path d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 12.977h-4.923v4.896q0 .401-.281.682t-.682.281v0q-.375 0-.669-.281t-.294-.682v-4.896h-4.923q-.401 0-.682-.294t-.281-.669v0q0-.401.281-.682t.682-.281h4.923v-4.896q0-.401.294-.682t.669-.281v0q.401 0 .682.281t.281.682v4.896h4.923q.401 0 .682.281t.281.682v0q0 .375-.281.669t-.682.294z" />
</SvgIcon>
);
}
export const StyledTreeItem = withStyles((theme: Theme) =>
createStyles({
iconContainer: {
'& .close': {
opacity: 0.3,
},
},
group: {
marginLeft: 5,
marginBottom: 5,
paddingLeft: 8,
paddingBottom: 8,
borderLeft: `1px dashed ${fade(theme.palette.text.primary, 0.4)}`,
borderBottom: `1px dashed ${fade(theme.palette.text.primary, 0.4)}`,
borderRadius: "0 0 0px 5px",
},
}),
)((props: TreeItemProps) => <TreeItem {...props} />);
import React, {ChangeEvent} from "react";
import {
AccordionDetails,
Button,
Checkbox,
FormControlLabel,
IconButton,
TextField,
Tooltip,
Typography
} from "@material-ui/core";
import {CenterFocusWeak, DoubleArrow, ExpandMore, OpenInNew} from "@material-ui/icons";
import {_3DContainer, Collections, Type} from "ogc3dcontainerentitiesmodel";
import {TilesetInfo} from "../Map";
import {TreeView} from "@material-ui/lab";
import {MinusSquare, PlusSquare, StyledTreeItem} from "../StyledTreeItems";
import {StyledAccordion, StyledAccordionSummary} from "../StyledAccordion";
type ContainerProps = {
onTilesetAdded: (tilesetInfo: TilesetInfo) => void;
onTilesetRemoved: (id: string) => void;
getVisibleItemsIds: () => string[];
flyToTileset: (tilesetId: string) => void;
}
type ContainerState = {
url: string,
collections: Collections | null
}
export class OGC3DContainerLayer extends React.Component<ContainerProps, ContainerState> {
constructor(props: ContainerProps) {
super(props);
this.state = {
url: "http://localhost:3001/collections",
collections: null
};
}
handleUrlChange(element: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
this.setState({url: element.target.value});
}
loadData() {
fetch(this.state.url)
.then(response => response.json())
.then(data => this.setState({collections: Collections.fromJson(data)}))
.catch(err => {
this.setState({collections: null});
console.error(err);
});
}
render() {
const {url, collections} = this.state;
return <StyledAccordion square>
<StyledAccordionSummary expandIcon={<ExpandMore/>}>
<Typography>3D Container</Typography>
</StyledAccordionSummary>
<AccordionDetails style={{flexDirection: "column"}}>
<div style={{padding: 5, display: "flex", flex: 1}}>
<TextField style={{flex: 1}} label={"3D Container Url"} value={url} onChange={this.handleUrlChange.bind(this)}/>
<IconButton type="submit" aria-label="search" onClick={this.loadData.bind(this)}>
<DoubleArrow/>
</IconButton>
</div>
{collections?.collections ?
<TreeView
defaultCollapseIcon={<MinusSquare/>}
defaultExpandIcon={<PlusSquare/>}>
{collections.collections.map(collection => (
<CollectionInfo
deepId={collection.id}
key={collection.id}
collection={collection}
onTilesetAdded={this.props.onTilesetAdded}
onTilesetRemoved={this.props.onTilesetRemoved}
getVisibleItemsIds={this.props.getVisibleItemsIds}
flyToTileset={this.props.flyToTileset}
/>
))}
</TreeView>
: null
}
</AccordionDetails>
</StyledAccordion>;
}
}
type CollectionProps = {
deepId: string;
collection: _3DContainer;
onTilesetAdded: (tilesetInfo: TilesetInfo) => void;
onTilesetRemoved: (id: string) => void;
getVisibleItemsIds: () => string[];
flyToTileset: (tilesetId: string) => void;
}
type CollectionState = {}
class CollectionInfo extends React.Component<CollectionProps, CollectionState> {
render() {
const {collection} = this.props;
const deepId = `${this.props.deepId}`;
return <StyledTreeItem nodeId={deepId} label={collection.title}>
{collection?.children?.length ? (
collection.children.map(subCollection => (
<CollectionInfo
key={`${deepId}-${subCollection.id}`}
deepId={`${deepId}-${subCollection.id}`}
collection={subCollection}
onTilesetAdded={this.props.onTilesetAdded}
onTilesetRemoved={this.props.onTilesetRemoved}
getVisibleItemsIds={this.props.getVisibleItemsIds}
flyToTileset={this.props.flyToTileset}
/>
))
) : (<div
style={{
display: "flex",
justifyContent: "space-evenly",
alignItems: "center"
}}>
<CollectionContent
deepId={`${deepId}-${collection.id}`}
key={`${deepId}-${collection.id}`}
collection={collection}
onTilesetAdded={this.props.onTilesetAdded}
onTilesetRemoved={this.props.onTilesetRemoved}
getVisibleItemsIds={this.props.getVisibleItemsIds}
flyToTileset={this.props.flyToTileset}
/>
</div>)
}
</StyledTreeItem>;
}
}
type CheckedDictionary = { [tilesetId: string]: boolean }
type CollectionContentState = {
checked: CheckedDictionary;
}
type CollectionContentProps = {
deepId: string;
collection: _3DContainer;
onTilesetAdded: (tilesetInfo: TilesetInfo) => void;
onTilesetRemoved: (id: string) => void;
getVisibleItemsIds: () => string[];
flyToTileset: (tilesetId: string) => void;
}
class CollectionContent extends React.Component<CollectionContentProps, CollectionContentState> {
constructor(props: CollectionContentProps) {
super(props);
this.state = {
checked: this.resolveChecked()
};
}
private resolveChecked(): CheckedDictionary {
return this.props.collection.content?.reduce((obj, link) => {
const id = this.getTilesetId(link.type);
return {...obj, [id]: this.isChecked(id)};
}, {}) || {};
}
handleCheckboxChange(tileset: TilesetInfo, checked: boolean) {
if (checked)
this.props.onTilesetAdded(tileset);
else
this.props.onTilesetRemoved(tileset.id);
this.setState(state => ({
checked: {
...state.checked,
[tileset.id]: checked
}
}));
}
isChecked(tilesetId: string) {
return this.props.getVisibleItemsIds().some(id => id === tilesetId);
}
getTilesetId(linkType?: Type) {
return `${this.props.deepId}-${linkType}`;
}
render() {
const {collection} = this.props;
return collection.content?.map(link => {
const controlProvider = (linkType?: Type) => {
const tilesetId = this.getTilesetId(linkType);
// noinspection JSUnreachableSwitchBranches
switch (linkType) {
// @ts-ignore
case "application/json+3dtiles":
case "application/json-3dtiles":
return (<span>
<FormControlLabel
label="3D Tiles"
control={
<Checkbox
color={"primary"}
checked={this.state.checked[tilesetId]}
onChange={(e) => this.handleCheckboxChange(
{
id: tilesetId,
link
},
e.target.checked)}/>
}/>
<Tooltip title={"Fly to"} style={{visibility: this.state.checked[tilesetId] ? "unset" : "hidden"}}>
<span>
<IconButton
size={"small"}
onClick={() => this.props.flyToTileset(tilesetId)}
>
<CenterFocusWeak/>
</IconButton>
</span>
</Tooltip>
</span>);
default:
return (<Button
size={"small"}
endIcon={<OpenInNew/>}
variant={"contained"}
color={"primary"}
target="_blank"
rel="noopener noreferrer"
href={link.href!}>{linkType === "application/json-i3s" ? "I3S" : linkType}</Button>);
}
};
return <span key={`${collection.id}-${link.type}`}>
{controlProvider(link.type)}
</span>;
}) || (<div>No Content!</div>);
}
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment