Adding a community index view with Svelte (WIP)

This commit is contained in:
Pieter Vander Vennet 2023-01-29 13:10:57 +01:00
parent ad13444883
commit d30ed22673
10 changed files with 487 additions and 101 deletions

View file

@ -2,8 +2,10 @@ import { QueryParameters } from "../Web/QueryParameters"
import { BBox } from "../BBox"
import Constants from "../../Models/Constants"
import { GeoLocationPointProperties, GeoLocationState } from "../State/GeoLocationState"
import State from "../../State"
import { UIEventSource } from "../UIEventSource"
import Loc from "../../Models/Loc"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource"
/**
* The geolocation-handler takes a map-location and a geolocation state.
@ -12,12 +14,24 @@ import { UIEventSource } from "../UIEventSource"
*/
export default class GeoLocationHandler {
public readonly geolocationState: GeoLocationState
private readonly _state: State
private readonly _state: {
currentUserLocation: SimpleFeatureSource
layoutToUse: LayoutConfig
locationControl: UIEventSource<Loc>
selectedElement: UIEventSource<any>
leafletMap?: UIEventSource<any>
}
public readonly mapHasMoved: UIEventSource<boolean> = new UIEventSource<boolean>(false)
constructor(
geolocationState: GeoLocationState,
state: State // { locationControl: UIEventSource<Loc>, selectedElement: UIEventSource<any>, leafletMap?: UIEventSource<any> })
state: {
locationControl: UIEventSource<Loc>
currentUserLocation: SimpleFeatureSource
layoutToUse: LayoutConfig
selectedElement: UIEventSource<any>
leafletMap?: UIEventSource<any>
}
) {
this.geolocationState = geolocationState
this._state = state

View file

@ -209,7 +209,7 @@ export class GeoOperations {
* GeoOperations.inside([1.42822265625, 48.61838518688487], multiPolygon) // => false
* GeoOperations.inside([4.02099609375, 47.81315451752768], multiPolygon) // => false
*/
public static inside(pointCoordinate, feature): boolean {
public static inside(pointCoordinate: [number, number] | Feature<Point>, feature): boolean {
// ray-casting algorithm based on
// http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html
@ -217,8 +217,8 @@ export class GeoOperations {
return false
}
if (pointCoordinate.geometry !== undefined) {
pointCoordinate = pointCoordinate.geometry.coordinates
if (pointCoordinate["geometry"] !== undefined) {
pointCoordinate = pointCoordinate["geometry"].coordinates
}
const x: number = pointCoordinate[0]

View file

@ -1,4 +1,5 @@
import { Utils } from "../Utils"
import { Readable, Subscriber, Unsubscriber } from "svelte/store"
/**
* Various static utils
@ -88,7 +89,7 @@ export class Stores {
}
}
export abstract class Store<T> {
export abstract class Store<T> implements Readable<T> {
abstract readonly data: T
/**
@ -113,6 +114,18 @@ export abstract class Store<T> {
abstract map<J>(f: (t: T) => J): Store<J>
abstract map<J>(f: (t: T) => J, extraStoresToWatch: Store<any>[]): Store<J>
public mapD<J>(f: (t: T) => J, extraStoresToWatch: Store<any>[]): Store<J> {
return this.map((t) => {
if (t === undefined) {
return undefined
}
if (t === null) {
return null
}
return f(t)
}, extraStoresToWatch)
}
/**
* Add a callback function which will run on future data changes
*/
@ -258,6 +271,17 @@ export abstract class Store<T> {
}
})
}
/**
* Same as 'addCallbackAndRun', added to be compatible with Svelte
* @param run
* @param invalidate
*/
public subscribe(run: Subscriber<T> & ((value: T) => void), invalidate?): Unsubscriber {
// We don't need to do anything with 'invalidate', see
// https://github.com/sveltejs/svelte/issues/3859
return this.addCallbackAndRun(run)
}
}
export class ImmutableStore<T> extends Store<T> {

View file

@ -1,13 +1,26 @@
import BaseUIElement from "../BaseUIElement"
import { SvelteComponentTyped } from "svelte"
/**
* The SvelteUIComponent serves as a translating class which which wraps a SvelteElement into the BaseUIElement framework.
*/
export default class SvelteUIElement extends BaseUIElement {
private readonly _svelteComponent
private readonly _props: Record<string, any>
export default class SvelteUIElement<
Props extends Record<string, any> = any,
Events extends Record<string, any> = any,
Slots extends Record<string, any> = any
> extends BaseUIElement {
private readonly _svelteComponent: {
new (args: {
target: HTMLElement
props: Props
events?: Events
slots?: Slots
}): SvelteComponentTyped<Props, Events, Slots>
}
private readonly _props: Props
constructor(svelteElement, props: Record<string, any>) {
constructor(svelteElement, props: Props) {
super()
this._svelteComponent = svelteElement
this._props = props

View file

@ -0,0 +1,14 @@
<script lang="ts">
import {BBox} from "../../Logic/BBox";
import {Store} from "../../Logic/UIEventSource";
import Loc from "../../Models/Loc";
export let bbox: Store<BBox>
export let currentLocation: Store<Loc>
bbox.mapD(bbox => {
if(currentLocation.data.zoom <= 6){
// only return the global data
}
return bbox.expandToTileBounds(6);
}, [currentLocation])
</script>

View file

@ -0,0 +1,31 @@
<!-- A contact link indicates how a mapper can contact their local community -->
<script lang="ts">
import {Store} from "../../Logic/UIEventSource";
<!-- The _properties_ of a community feature -->
export let country: Store<{ resources; nameEn: string }>
let resources = country.mapD(country => Object.values(country?.resources ?? {}))
</script>
<div>
{#if $country?.nameEn}
<h3>{$country?.nameEn}</h3>
{/if}
{#each $resources as resource}
<div class="flex link-underline items-center">
<img
class="w-8 h-8 m-2"
src={"https://raw.githubusercontent.com/osmlab/osm-community-index/main/dist/img/" +
resource.type +
".svg"}
/>
<div class="flex flex-col">
<a href={resource.resolved.url} target="_blank" class="font-bold">
{resource.resolved.name ?? resource.resolved.url}
</a>
{resource.resolved?.description}
</div>
</div>
{/each}
</div>

View file

@ -0,0 +1,355 @@
{
"OSM-Discord": {
"id": "OSM-Discord",
"type": "discord",
"account": "openstreetmap",
"locationSet": {
"include": [
"001"
]
},
"languageCodes": [
"de",
"en",
"es",
"fr",
"it",
"pt-BR",
"ro",
"tr"
],
"order": 6,
"strings": {
"name": "OpenStreetMap World Discord"
},
"contacts": [
{
"name": "Austin Harrison",
"email": "jaustinharrison@gmail.com"
}
],
"resolved": {
"name": "OpenStreetMap World Discord",
"url": "https://discord.gg/openstreetmap",
"description": "Get in touch with other mappers on Discord",
"nameHTML": "<a target=\"_blank\" href=\"https://discord.gg/openstreetmap\">OpenStreetMap World Discord</a>",
"urlHTML": "<a target=\"_blank\" href=\"https://discord.gg/openstreetmap\">https://discord.gg/openstreetmap</a>",
"descriptionHTML": "Get in touch with other mappers on Discord"
}
},
"OSM-Discourse": {
"id": "OSM-Discourse",
"type": "discourse",
"locationSet": {
"include": [
"001"
]
},
"languageCodes": [
"de",
"en",
"es",
"nl",
"pl",
"pt-BR"
],
"order": 7,
"strings": {
"name": "OpenStreetMap Discourse",
"description": "A shared place for conversations about OpenStreetMap",
"url": "https://community.openstreetmap.org/"
},
"contacts": [
{
"name": "Grant Slater",
"email": "osmfuture@firefishy.com"
},
{
"name": "Rubén Martín",
"email": "nukeador@protonmail.com"
}
],
"resolved": {
"name": "OpenStreetMap Discourse",
"url": "https://community.openstreetmap.org/",
"description": "A shared place for conversations about OpenStreetMap",
"nameHTML": "<a target=\"_blank\" href=\"https://community.openstreetmap.org/\">OpenStreetMap Discourse</a>",
"urlHTML": "<a target=\"_blank\" href=\"https://community.openstreetmap.org/\">https://community.openstreetmap.org/</a>",
"descriptionHTML": "A shared place for conversations about OpenStreetMap"
}
},
"OSM-Facebook": {
"id": "OSM-Facebook",
"type": "facebook",
"account": "OpenStreetMap",
"locationSet": {
"include": [
"001"
]
},
"languageCodes": [
"en"
],
"order": 3,
"strings": {
"community": "OpenStreetMap",
"communityID": "openstreetmap",
"description": "Like us on Facebook for news and updates about OpenStreetMap."
},
"contacts": [
{
"name": "Harry Wood",
"email": "mail@harrywood.co.uk"
}
],
"resolved": {
"name": "OpenStreetMap on Facebook",
"url": "https://www.facebook.com/OpenStreetMap",
"description": "Like us on Facebook for news and updates about OpenStreetMap.",
"nameHTML": "<a target=\"_blank\" href=\"https://www.facebook.com/OpenStreetMap\">OpenStreetMap on Facebook</a>",
"urlHTML": "<a target=\"_blank\" href=\"https://www.facebook.com/OpenStreetMap\">https://www.facebook.com/OpenStreetMap</a>",
"descriptionHTML": "Like us on Facebook for news and updates about OpenStreetMap."
}
},
"OSM-help": {
"id": "OSM-help",
"type": "forum",
"locationSet": {
"include": [
"001"
]
},
"languageCodes": [
"en"
],
"order": -2,
"strings": {
"name": "OpenStreetMap Help",
"description": "Ask a question and get answers on OSM's community-driven question and answer site.",
"extendedDescription": "{url} is for everyone who needs help with OpenStreetMap. Whether you are a beginner mapper or have a technical question, we're here to help!",
"url": "https://help.openstreetmap.org/"
},
"contacts": [
{
"name": "OSMF Operations",
"email": "operations@osmfoundation.org"
}
],
"resolved": {
"name": "OpenStreetMap Help",
"url": "https://help.openstreetmap.org/",
"description": "Ask a question and get answers on OSM's community-driven question and answer site.",
"extendedDescription": "https://help.openstreetmap.org/ is for everyone who needs help with OpenStreetMap. Whether you are a beginner mapper or have a technical question, we're here to help!",
"nameHTML": "<a target=\"_blank\" href=\"https://help.openstreetmap.org/\">OpenStreetMap Help</a>",
"urlHTML": "<a target=\"_blank\" href=\"https://help.openstreetmap.org/\">https://help.openstreetmap.org/</a>",
"descriptionHTML": "Ask a question and get answers on OSM's community-driven question and answer site.",
"extendedDescriptionHTML": "<a target=\"_blank\" href=\"https://help.openstreetmap.org/\">https://help.openstreetmap.org/</a> is for everyone who needs help with OpenStreetMap. Whether you are a beginner mapper or have a technical question, we're here to help!"
}
},
"OSM-IRC": {
"id": "OSM-IRC",
"type": "irc",
"account": "osm",
"locationSet": {
"include": [
"001"
]
},
"languageCodes": [
"en"
],
"order": -4,
"strings": {
"community": "OpenStreetMap",
"communityID": "openstreetmap"
},
"contacts": [
{
"name": "Harry Wood",
"email": "mail@harrywood.co.uk"
}
],
"resolved": {
"name": "OpenStreetMap on IRC",
"url": "https://webchat.oftc.net/?channels=osm",
"description": "Join #osm on irc.oftc.net (port 6667)",
"nameHTML": "<a target=\"_blank\" href=\"https://webchat.oftc.net/?channels=osm\">OpenStreetMap on IRC</a>",
"urlHTML": "<a target=\"_blank\" href=\"https://webchat.oftc.net/?channels=osm\">https://webchat.oftc.net/?channels=osm</a>",
"descriptionHTML": "Join #osm on irc.oftc.net (port 6667)"
}
},
"OSM-Mastodon": {
"id": "OSM-Mastodon",
"type": "mastodon",
"account": "openstreetmap",
"locationSet": {
"include": [
"001"
]
},
"languageCodes": [
"en"
],
"order": 3,
"strings": {
"community": "OpenStreetMap",
"communityID": "openstreetmap",
"url": "https://en.osm.town/@openstreetmap"
},
"contacts": [
{
"name": "Harry Wood",
"email": "mail@harrywood.co.uk"
}
],
"resolved": {
"name": "OpenStreetMap Mastodon Account",
"url": "https://en.osm.town/@openstreetmap",
"description": "The official Mastodon account for OpenStreetMap",
"nameHTML": "<a target=\"_blank\" href=\"https://en.osm.town/@openstreetmap\">OpenStreetMap Mastodon Account</a>",
"urlHTML": "<a target=\"_blank\" href=\"https://en.osm.town/@openstreetmap\">https://en.osm.town/@openstreetmap</a>",
"descriptionHTML": "The official Mastodon account for OpenStreetMap"
}
},
"OSM-Reddit": {
"id": "OSM-Reddit",
"type": "reddit",
"account": "openstreetmap",
"locationSet": {
"include": [
"001"
]
},
"languageCodes": [
"en"
],
"order": 2,
"strings": {
"community": "OpenStreetMap",
"communityID": "openstreetmap",
"description": "/r/{account} is a great place to learn more about OpenStreetMap. Ask us anything!"
},
"contacts": [
{
"name": "Serge Wroclawski",
"email": "emacsen@gmail.com"
}
],
"resolved": {
"name": "OpenStreetMap on Reddit",
"url": "https://www.reddit.com/r/openstreetmap",
"description": "/r/openstreetmap is a great place to learn more about OpenStreetMap. Ask us anything!",
"nameHTML": "<a target=\"_blank\" href=\"https://www.reddit.com/r/openstreetmap\">OpenStreetMap on Reddit</a>",
"urlHTML": "<a target=\"_blank\" href=\"https://www.reddit.com/r/openstreetmap\">https://www.reddit.com/r/openstreetmap</a>",
"descriptionHTML": "<a target=\"_blank\" href=\"https://www.reddit.com/r/openstreetmap\">/r/openstreetmap</a> is a great place to learn more about OpenStreetMap. Ask us anything!"
}
},
"OSM-Telegram": {
"id": "OSM-Telegram",
"type": "telegram",
"account": "OpenStreetMapOrg",
"locationSet": {
"include": [
"001"
]
},
"languageCodes": [
"en"
],
"order": 5,
"strings": {
"community": "OpenStreetMap",
"communityID": "openstreetmap",
"description": "Join the OpenStreetMap Telegram global supergroup at {url}"
},
"contacts": [
{
"name": "Max N",
"email": "abonnements@revolwear.com"
}
],
"resolved": {
"name": "OpenStreetMap Telegram",
"url": "https://t.me/OpenStreetMapOrg",
"description": "Join the OpenStreetMap Telegram global supergroup at https://t.me/OpenStreetMapOrg",
"nameHTML": "<a target=\"_blank\" href=\"https://t.me/OpenStreetMapOrg\">OpenStreetMap Telegram</a>",
"urlHTML": "<a target=\"_blank\" href=\"https://t.me/OpenStreetMapOrg\">https://t.me/OpenStreetMapOrg</a>",
"descriptionHTML": "Join the OpenStreetMap Telegram global supergroup at <a target=\"_blank\" href=\"https://t.me/OpenStreetMapOrg\">https://t.me/OpenStreetMapOrg</a>"
}
},
"OSM-Twitter": {
"id": "OSM-Twitter",
"type": "twitter",
"account": "openstreetmap",
"locationSet": {
"include": [
"001"
]
},
"languageCodes": [
"en"
],
"order": 4,
"strings": {
"community": "OpenStreetMap",
"communityID": "openstreetmap"
},
"contacts": [
{
"name": "Harry Wood",
"email": "mail@harrywood.co.uk"
}
],
"resolved": {
"name": "OpenStreetMap on Twitter",
"url": "https://twitter.com/openstreetmap",
"description": "Follow us on Twitter",
"nameHTML": "<a target=\"_blank\" href=\"https://twitter.com/openstreetmap\">OpenStreetMap on Twitter</a>",
"urlHTML": "<a target=\"_blank\" href=\"https://twitter.com/openstreetmap\">https://twitter.com/openstreetmap</a>",
"descriptionHTML": "Follow us on Twitter"
}
},
"OSMF": {
"id": "OSMF",
"type": "osm-lc",
"locationSet": {
"include": [
"001"
]
},
"languageCodes": [
"en",
"fr",
"it",
"ja",
"nl",
"ru"
],
"order": 10,
"strings": {
"name": "OpenStreetMap Foundation",
"description": "OSMF is a UK-based not-for-profit that supports the OpenStreetMap Project",
"extendedDescription": "OSMF supports the OpenStreetMap project by fundraising, maintaining the servers which power OSM, organizing the annual State of the Map conference, and coordinating the volunteers who keep OSM running. You can show your support and have a voice in the direction of OpenStreetMap by joining as an OSMF member here: {signupUrl}",
"signupUrl": "https://join.osmfoundation.org/",
"url": "https://wiki.osmfoundation.org/wiki/Main_Page"
},
"contacts": [
{
"name": "OSMF Board",
"email": "board@osmfoundation.org"
}
],
"resolved": {
"name": "OpenStreetMap Foundation",
"url": "https://wiki.osmfoundation.org/wiki/Main_Page",
"signupUrl": "https://join.osmfoundation.org/",
"description": "OSMF is a UK-based not-for-profit that supports the OpenStreetMap Project",
"extendedDescription": "OSMF supports the OpenStreetMap project by fundraising, maintaining the servers which power OSM, organizing the annual State of the Map conference, and coordinating the volunteers who keep OSM running. You can show your support and have a voice in the direction of OpenStreetMap by joining as an OSMF member here: https://join.osmfoundation.org/",
"nameHTML": "<a target=\"_blank\" href=\"https://wiki.osmfoundation.org/wiki/Main_Page\">OpenStreetMap Foundation</a>",
"urlHTML": "<a target=\"_blank\" href=\"https://wiki.osmfoundation.org/wiki/Main_Page\">https://wiki.osmfoundation.org/wiki/Main_Page</a>",
"signupUrlHTML": "<a target=\"_blank\" href=\"https://join.osmfoundation.org/\">https://join.osmfoundation.org/</a>",
"descriptionHTML": "OSMF is a UK-based not-for-profit that supports the OpenStreetMap Project",
"extendedDescriptionHTML": "OSMF supports the OpenStreetMap project by fundraising, maintaining the servers which power OSM, organizing the annual State of the Map conference, and coordinating the volunteers who keep OSM running. You can show your support and have a voice in the direction of OpenStreetMap by joining as an OSMF member here: <a target=\"_blank\" href=\"https://join.osmfoundation.org/\">https://join.osmfoundation.org/</a>"
}
}
}

2
package-lock.json generated
View file

@ -72,7 +72,7 @@
"dependency-cruiser": "^10.4.0",
"fs": "0.0.1-security",
"mocha": "^9.2.2",
"prettier": "2.7.1",
"prettier": "^2.7.1",
"prettier-plugin-svelte": "^2.9.0",
"read-file": "^0.2.0",
"sass": "^1.57.1",

View file

@ -41,7 +41,7 @@
"generate": "mkdir -p ./assets/generated; npm run generate:licenses; npm run generate:images; npm run generate:charging-stations; npm run generate:translations; npm run reset:layeroverview; npm run generate:service-worker",
"generate:charging-stations": "cd ./assets/layers/charging_station && ts-node csvToJson.ts && cd -",
"prepare-deploy": "npm run generate:service-worker && ./scripts/build.sh",
"format": "npx prettier --write '**/*.ts'",
"format": "npx prettier --write --svelte-bracket-new-line=false --html-whitespace-sensitivity=ignore '**/*.ts' '**/*.svelte'",
"clean:tests": "(find . -type f -name \"*.doctest.ts\" | xargs rm)",
"clean": "rm -rf .cache/ && (find *.html | grep -v \"^\\(404\\|index\\|land\\|test\\|preferences\\|customGenerator\\|professional\\|automaton\\|import_helper\\|import_viewer\\|theme\\).html\" | xargs rm) && (ls | grep \"^index_[a-zA-Z_-]\\+\\.ts$\" | xargs rm) && (ls | grep \".*.webmanifest$\" | grep -v \"manifest.webmanifest\" | xargs rm)",
"generate:dependency-graph": "node_modules/.bin/depcruise --exclude \"^node_modules\" --output-type dot Logic/State/MapState.ts > dependencies.dot && dot dependencies.dot -T svg -o dependencies.svg && rm dependencies.dot",
@ -127,7 +127,7 @@
"dependency-cruiser": "^10.4.0",
"fs": "0.0.1-security",
"mocha": "^9.2.2",
"prettier": "2.7.1",
"prettier": "^2.7.1",
"prettier-plugin-svelte": "^2.9.0",
"read-file": "^0.2.0",
"sass": "^1.57.1",

107
test.ts
View file

@ -1,90 +1,25 @@
import MangroveReviewsOfFeature, { MangroveIdentity } from "./Logic/Web/MangroveReviews"
import { Feature, Point } from "geojson"
import { OsmTags } from "./Models/OsmFeature"
import { VariableUiElement } from "./UI/Base/VariableUIElement"
import ContactLink from "./UI/BigComponents/ContactLink.svelte"
import SvelteUIElement from "./UI/Base/SvelteUIElement"
import { Utils } from "./Utils"
import List from "./UI/Base/List"
import { UIEventSource } from "./Logic/UIEventSource"
import UserRelatedState from "./Logic/State/UserRelatedState"
import { GeoOperations } from "./Logic/GeoOperations"
import { Tiles } from "./Models/TileRange"
import { Stores } from "./Logic/UIEventSource"
const feature: Feature<Point, OsmTags> = {
type: "Feature",
id: "node/6739848322",
properties: {
"addr:city": "San Diego",
"addr:housenumber": "2816",
"addr:postcode": "92106",
"addr:street": "Historic Decatur Road",
"addr:unit": "116",
amenity: "restaurant",
cuisine: "burger",
delivery: "yes",
"diet:halal": "no",
"diet:vegetarian": "yes",
dog: "yes",
image: "https://i.imgur.com/AQlGNHQ.jpg",
internet_access: "wlan",
"internet_access:fee": "no",
"internet_access:ssid": "Public-stinebrewingCo",
microbrewery: "yes",
name: "Stone Brewing World Bistro & Gardens",
opening_hours: "Mo-Fr, Su 11:30-21:00; Sa 11:30-22:00",
organic: "no",
"payment:cards": "yes",
"payment:cash": "yes",
"service:electricity": "ask",
takeaway: "yes",
website: "https://www.stonebrewing.com/visit/bistros/liberty-station",
wheelchair: "designated",
"_last_edit:contributor": "Drew Dowling",
"_last_edit:timestamp": "2023-01-11T23:22:28Z",
id: "node/6739848322",
timestamp: "2023-01-11T23:22:28Z",
user: "Drew Dowling",
_backend: "https://www.openstreetmap.org",
_lat: "32.7404614",
_lon: "-117.211684",
_layer: "food",
_length: "0",
"_length:km": "0.0",
"_now:date": "2023-01-20",
"_now:datetime": "2023-01-20 17:46:54",
"_loaded:date": "2023-01-20",
"_loaded:datetime": "2023-01-20 17:46:54",
"_geometry:type": "Point",
_surface: "0",
"_surface:ha": "0",
_country: "us",
},
geometry: {
type: "Point",
coordinates: [0, 0],
},
async function main() {
const location: [number, number] = [3.21, 51.2]
const t = Tiles.embedded_tile(location[1], location[0], 6)
const url = `https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/community_index/tile_${t.z}_${t.x}_${t.y}.geojson`
const be = Stores.FromPromise(Utils.downloadJson(url)).mapD(
(data) => data.features.find((f) => GeoOperations.inside(location, f)).properties
)
new SvelteUIElement(ContactLink, { country: be }).AttachTo("maindiv")
/*
const links = data.features
.filter((f) => GeoOperations.inside(location, f))
.map((f) => new SvelteUIElement(ContactLink, { country: f.properties }))
new List(links).AttachTo("maindiv")
//*/
}
const state = new UserRelatedState(undefined)
state.allElements.addOrGetElement(feature)
const reviews = MangroveReviewsOfFeature.construct(feature, state)
reviews.reviews.addCallbackAndRun((r) => {
console.log("Reviews are:", r)
})
window.setTimeout(async () => {
await reviews.createReview({
opinion: "Cool bar",
rating: 90,
metadata: {
nickname: "Pietervdvn",
},
})
console.log("Submitted review")
}, 1000)
new VariableUiElement(
reviews.reviews.map(
(reviews) =>
new List(
reviews.map((r) => r.rating + "% " + r.opinion + " (" + r.metadata.nickname + ")")
)
)
).AttachTo("maindiv")
main().then((_) => {})