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}`;
}
This diff is collapsed.
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