forked from MapComplete/MapComplete
		
	
		
			
				
	
	
		
			325 lines
		
	
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			325 lines
		
	
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| /**
 | |
|  * The statistics-gui shows statistics from previous MapComplete-edits
 | |
|  */
 | |
| import { UIEventSource } from "../Logic/UIEventSource"
 | |
| import { VariableUiElement } from "./Base/VariableUIElement"
 | |
| import Loading from "./Base/Loading"
 | |
| import { Utils } from "../Utils"
 | |
| import Combine from "./Base/Combine"
 | |
| import { StackedRenderingChart } from "./BigComponents/TagRenderingChart"
 | |
| import { LayerFilterPanel } from "./BigComponents/FilterView"
 | |
| import { AllKnownLayouts } from "../Customizations/AllKnownLayouts"
 | |
| import MapState from "../Logic/State/MapState"
 | |
| import BaseUIElement from "./BaseUIElement"
 | |
| import Title from "./Base/Title"
 | |
| import { FixedUiElement } from "./Base/FixedUiElement"
 | |
| import List from "./Base/List"
 | |
| 
 | |
| class StatisticsForOverviewFile extends Combine {
 | |
|     constructor(homeUrl: string, paths: string[]) {
 | |
|         paths = paths.filter((p) => !p.endsWith("file-overview.json"))
 | |
|         const layer = AllKnownLayouts.allKnownLayouts.get("mapcomplete-changes").layers[0]
 | |
|         const filteredLayer = MapState.InitializeFilteredLayers(
 | |
|             { id: "statistics-view", layers: [layer] },
 | |
|             undefined
 | |
|         )[0]
 | |
|         const filterPanel = new LayerFilterPanel(undefined, filteredLayer)
 | |
|         const appliedFilters = filteredLayer.appliedFilters
 | |
| 
 | |
|         const downloaded = new UIEventSource<{ features: ChangeSetData[] }[]>([])
 | |
| 
 | |
|         for (const filepath of paths) {
 | |
|             if (filepath.endsWith("file-overview.json")) {
 | |
|                 continue
 | |
|             }
 | |
|             Utils.downloadJson(homeUrl + filepath).then((data) => {
 | |
|                 if (data === undefined) {
 | |
|                     return
 | |
|                 }
 | |
|                 if (data.features === undefined) {
 | |
|                     data.features = data
 | |
|                 }
 | |
|                 data?.features?.forEach((item) => {
 | |
|                     item.properties = { ...item.properties, ...item.properties.metadata }
 | |
|                     delete item.properties.metadata
 | |
|                 })
 | |
|                 downloaded.data.push(data)
 | |
|                 downloaded.ping()
 | |
|             })
 | |
|         }
 | |
| 
 | |
|         const loading = new Loading(
 | |
|             new VariableUiElement(
 | |
|                 downloaded.map((dl) => "Downloaded " + dl.length + " items out of " + paths.length)
 | |
|             )
 | |
|         )
 | |
| 
 | |
|         super([
 | |
|             filterPanel,
 | |
|             new VariableUiElement(
 | |
|                 downloaded.map(
 | |
|                     (downloaded) => {
 | |
|                         if (downloaded.length !== paths.length) {
 | |
|                             return loading
 | |
|                         }
 | |
| 
 | |
|                         let overview = ChangesetsOverview.fromDirtyData(
 | |
|                             [].concat(...downloaded.map((d) => d.features))
 | |
|                         )
 | |
|                         if (appliedFilters.data.size > 0) {
 | |
|                             appliedFilters.data.forEach((filterSpec) => {
 | |
|                                 const tf = filterSpec?.currentFilter
 | |
|                                 if (tf === undefined) {
 | |
|                                     return
 | |
|                                 }
 | |
|                                 overview = overview.filter((cs) =>
 | |
|                                     tf.matchesProperties(cs.properties)
 | |
|                                 )
 | |
|                             })
 | |
|                         }
 | |
| 
 | |
|                         if (overview._meta.length === 0) {
 | |
|                             return "No data matched the filter"
 | |
|                         }
 | |
| 
 | |
|                         const dateStrings = Utils.NoNull(
 | |
|                             overview._meta.map((cs) => cs.properties.date)
 | |
|                         )
 | |
|                         const dates: number[] = dateStrings.map((d) => new Date(d).getTime())
 | |
|                         const mindate = Math.min(...dates)
 | |
|                         const maxdate = Math.max(...dates)
 | |
| 
 | |
|                         const diffInDays = (maxdate - mindate) / (1000 * 60 * 60 * 24)
 | |
|                         console.log("Diff in days is ", diffInDays, "got", overview._meta.length)
 | |
|                         const trs = layer.tagRenderings.filter(
 | |
|                             (tr) => tr.mappings?.length > 0 || tr.freeform?.key !== undefined
 | |
|                         )
 | |
| 
 | |
|                         const allKeys = new Set<string>()
 | |
|                         for (const cs of overview._meta) {
 | |
|                             for (const propertiesKey in cs.properties) {
 | |
|                                 allKeys.add(propertiesKey)
 | |
|                             }
 | |
|                         }
 | |
|                         console.log("All keys:", allKeys)
 | |
| 
 | |
|                         const valuesToSum = [
 | |
|                             "create",
 | |
|                             "modify",
 | |
|                             "delete",
 | |
|                             "answer",
 | |
|                             "move",
 | |
|                             "deletion",
 | |
|                             "add-image",
 | |
|                             "plantnet-ai-detection",
 | |
|                             "import",
 | |
|                             "conflation",
 | |
|                             "link-image",
 | |
|                             "soft-delete",
 | |
|                         ]
 | |
| 
 | |
|                         const allThemes = Utils.Dedup(overview._meta.map((f) => f.properties.theme))
 | |
| 
 | |
|                         const excludedThemes = new Set<string>()
 | |
|                         if (allThemes.length > 1) {
 | |
|                             excludedThemes.add("grb")
 | |
|                             excludedThemes.add("etymology")
 | |
|                         }
 | |
|                         const summedValues = valuesToSum
 | |
|                             .map((key) => [key, overview.sum(key, excludedThemes)])
 | |
|                             .filter((kv) => kv[1] != 0)
 | |
|                             .map((kv) => kv.join(": "))
 | |
|                         const elements: BaseUIElement[] = [
 | |
|                             new Title(
 | |
|                                 allThemes.length === 1
 | |
|                                     ? "General statistics for " + allThemes[0]
 | |
|                                     : "General statistics (excluding etymology- and GRB-theme changes)"
 | |
|                             ),
 | |
|                             new Combine([
 | |
|                                 overview._meta.length + " changesets match the filters",
 | |
|                                 new List(summedValues),
 | |
|                             ]).SetClass("flex flex-col border rounded-xl"),
 | |
| 
 | |
|                             new Title("Breakdown"),
 | |
|                         ]
 | |
|                         for (const tr of trs) {
 | |
|                             let total = undefined
 | |
|                             if (tr.freeform?.key !== undefined) {
 | |
|                                 total = new Set(
 | |
|                                     overview._meta.map((f) => f.properties[tr.freeform.key])
 | |
|                                 ).size
 | |
|                             }
 | |
| 
 | |
|                             try {
 | |
|                                 elements.push(
 | |
|                                     new Combine([
 | |
|                                         new Title(tr.question ?? tr.id).SetClass("p-2"),
 | |
|                                         total > 1 ? total + " unique value" : undefined,
 | |
|                                         new StackedRenderingChart(tr, <any>overview._meta, {
 | |
|                                             period: diffInDays <= 367 ? "day" : "month",
 | |
|                                             groupToOtherCutoff:
 | |
|                                                 total > 50 ? 25 : total > 10 ? 3 : 0,
 | |
|                                         }).SetStyle("width: 100%; height: 600px"),
 | |
|                                     ]).SetClass("block border-2 border-subtle p-2 m-2 rounded-xl")
 | |
|                                 )
 | |
|                             } catch (e) {
 | |
|                                 console.log("Could not generate a chart", e)
 | |
|                                 elements.push(
 | |
|                                     new FixedUiElement(
 | |
|                                         "No relevant information for " + tr.question.txt
 | |
|                                     )
 | |
|                                 )
 | |
|                             }
 | |
|                         }
 | |
| 
 | |
|                         return new Combine(elements)
 | |
|                     },
 | |
|                     [appliedFilters]
 | |
|                 )
 | |
|             ).SetClass("block w-full h-full"),
 | |
|         ])
 | |
|         this.SetClass("block w-full h-full")
 | |
|     }
 | |
| }
 | |
| 
 | |
| export default class StatisticsGUI extends VariableUiElement {
 | |
|     private static readonly homeUrl =
 | |
|         "https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/changeset-metadata/"
 | |
|     private static readonly stats_files = "file-overview.json"
 | |
| 
 | |
|     constructor() {
 | |
|         const index = UIEventSource.FromPromise(
 | |
|             Utils.downloadJson(StatisticsGUI.homeUrl + StatisticsGUI.stats_files)
 | |
|         )
 | |
|         super(
 | |
|             index.map((paths) => {
 | |
|                 if (paths === undefined) {
 | |
|                     return new Loading("Loading overview...")
 | |
|                 }
 | |
| 
 | |
|                 return new StatisticsForOverviewFile(StatisticsGUI.homeUrl, paths)
 | |
|             })
 | |
|         )
 | |
|         this.SetClass("block w-full h-full")
 | |
|     }
 | |
| }
 | |
| 
 | |
| class ChangesetsOverview {
 | |
|     private static readonly theme_remappings = {
 | |
|         metamap: "maps",
 | |
|         groen: "buurtnatuur",
 | |
|         "updaten van metadata met mapcomplete": "buurtnatuur",
 | |
|         "Toevoegen of dit natuurreservaat toegangkelijk is": "buurtnatuur",
 | |
|         "wiki:mapcomplete/fritures": "fritures",
 | |
|         "wiki:MapComplete/Fritures": "fritures",
 | |
|         lits: "lit",
 | |
|         pomp: "cyclofix",
 | |
|         "wiki:user:joost_schouppe/campersite": "campersite",
 | |
|         "wiki-user-joost_schouppe-geveltuintjes": "geveltuintjes",
 | |
|         "wiki-user-joost_schouppe-campersite": "campersite",
 | |
|         "wiki-User-joost_schouppe-campersite": "campersite",
 | |
|         "wiki-User-joost_schouppe-geveltuintjes": "geveltuintjes",
 | |
|         "wiki:User:joost_schouppe/campersite": "campersite",
 | |
|         arbres: "arbres_llefia",
 | |
|         aed_brugge: "aed",
 | |
|         "https://llefia.org/arbres/mapcomplete.json": "arbres_llefia",
 | |
|         "https://llefia.org/arbres/mapcomplete1.json": "arbres_llefia",
 | |
|         "toevoegen of dit natuurreservaat toegangkelijk is": "buurtnatuur",
 | |
|         "testing mapcomplete 0.0.0": "buurtnatuur",
 | |
|         entrances: "indoor",
 | |
|         "https://raw.githubusercontent.com/osmbe/play/master/mapcomplete/geveltuinen/geveltuinen.json":
 | |
|             "geveltuintjes",
 | |
|     }
 | |
|     public readonly _meta: ChangeSetData[]
 | |
| 
 | |
|     public static fromDirtyData(meta: ChangeSetData[]) {
 | |
|         return new ChangesetsOverview(meta?.map((cs) => ChangesetsOverview.cleanChangesetData(cs)))
 | |
|     }
 | |
| 
 | |
|     private constructor(meta: ChangeSetData[]) {
 | |
|         this._meta = Utils.NoNull(meta)
 | |
|     }
 | |
| 
 | |
|     public filter(predicate: (cs: ChangeSetData) => boolean) {
 | |
|         return new ChangesetsOverview(this._meta.filter(predicate))
 | |
|     }
 | |
| 
 | |
|     public sum(key: string, excludeThemes: Set<string>): number {
 | |
|         let s = 0
 | |
|         for (const feature of this._meta) {
 | |
|             if (excludeThemes.has(feature.properties.theme)) {
 | |
|                 continue
 | |
|             }
 | |
|             const parsed = Number(feature.properties[key])
 | |
|             if (!isNaN(parsed)) {
 | |
|                 s += parsed
 | |
|             }
 | |
|         }
 | |
|         return s
 | |
|     }
 | |
| 
 | |
|     private static cleanChangesetData(cs: ChangeSetData): ChangeSetData {
 | |
|         if (cs === undefined) {
 | |
|             return undefined
 | |
|         }
 | |
|         if (cs.properties.editor?.startsWith("iD")) {
 | |
|             // We also fetch based on hashtag, so some edits with iD show up as well
 | |
|             return undefined
 | |
|         }
 | |
|         if (cs.properties.theme === undefined) {
 | |
|             cs.properties.theme = cs.properties.comment.substr(
 | |
|                 cs.properties.comment.lastIndexOf("#") + 1
 | |
|             )
 | |
|         }
 | |
|         cs.properties.theme = cs.properties.theme.toLowerCase()
 | |
|         const remapped = ChangesetsOverview.theme_remappings[cs.properties.theme]
 | |
|         cs.properties.theme = remapped ?? cs.properties.theme
 | |
|         if (cs.properties.theme.startsWith("https://raw.githubusercontent.com/")) {
 | |
|             cs.properties.theme =
 | |
|                 "gh://" + cs.properties.theme.substr("https://raw.githubusercontent.com/".length)
 | |
|         }
 | |
|         if (cs.properties.modify + cs.properties.delete + cs.properties.create == 0) {
 | |
|             cs.properties.theme = "EMPTY CS"
 | |
|         }
 | |
|         try {
 | |
|             cs.properties.host = new URL(cs.properties.host).host
 | |
|         } catch (e) {}
 | |
|         return cs
 | |
|     }
 | |
| }
 | |
| 
 | |
| interface ChangeSetData {
 | |
|     id: number
 | |
|     type: "Feature"
 | |
|     geometry: {
 | |
|         type: "Polygon"
 | |
|         coordinates: [number, number][][]
 | |
|     }
 | |
|     properties: {
 | |
|         check_user: null
 | |
|         reasons: []
 | |
|         tags: []
 | |
|         features: []
 | |
|         user: string
 | |
|         uid: string
 | |
|         editor: string
 | |
|         comment: string
 | |
|         comments_count: number
 | |
|         source: string
 | |
|         imagery_used: string
 | |
|         date: string
 | |
|         reviewed_features: []
 | |
|         create: number
 | |
|         modify: number
 | |
|         delete: number
 | |
|         area: number
 | |
|         is_suspect: boolean
 | |
|         harmful: any
 | |
|         checked: boolean
 | |
|         check_date: any
 | |
|         host: string
 | |
|         theme: string
 | |
|         imagery: string
 | |
|         language: string
 | |
|     }
 | |
| }
 |