forked from MapComplete/MapComplete
		
	Refactoring: port "statisticsGUI" to svelte
This commit is contained in:
		
							parent
							
								
									f807f43399
								
							
						
					
					
						commit
						dc10a3fe56
					
				
					 8 changed files with 334 additions and 389 deletions
				
			
		
							
								
								
									
										87
									
								
								src/UI/Statistics/AllStats.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/UI/Statistics/AllStats.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,87 @@ | |||
| <script lang="ts"> | ||||
|   import { UIEventSource } from "../../Logic/UIEventSource" | ||||
|   import { Utils } from "../../Utils" | ||||
|   import Loading from "../Base/Loading.svelte" | ||||
|   import type { FeatureCollection } from "geojson" | ||||
|   import type { ChangeSetData } from "./ChangesetsOverview" | ||||
|   import { ChangesetsOverview } from "./ChangesetsOverview" | ||||
| 
 | ||||
|   import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig" | ||||
|   import mcChanges from "../../assets/generated/themes/mapcomplete-changes.json" | ||||
|   import type { ThemeConfigJson } from "../../Models/ThemeConfig/Json/ThemeConfigJson" | ||||
|   import { Accordion, AccordionItem } from "flowbite-svelte" | ||||
|   import AccordionSingle from "../Flowbite/AccordionSingle.svelte" | ||||
|   import Filterview from "../BigComponents/Filterview.svelte" | ||||
|   import FilteredLayer from "../../Models/FilteredLayer" | ||||
|   import type { OsmFeature } from "../../Models/OsmFeature" | ||||
|   import SingleStat from "./SingleStat.svelte" | ||||
|   import { DownloadIcon } from "@rgossiaux/svelte-heroicons/solid" | ||||
|   import { GeoOperations } from "../../Logic/GeoOperations" | ||||
| 
 | ||||
| 
 | ||||
|   export let paths: string[] | ||||
| 
 | ||||
|   let downloaded = 0 | ||||
|   const layer = new ThemeConfig(<ThemeConfigJson>mcChanges, true).layers[0] | ||||
|   const filteredLayer = new FilteredLayer(layer) | ||||
| 
 | ||||
|   let allData = <UIEventSource<(ChangeSetData & OsmFeature)[]>>UIEventSource.FromPromise( | ||||
|     Promise.all(paths.map(async p => { | ||||
|       const r = await Utils.downloadJson<FeatureCollection>(p) | ||||
|       downloaded++ | ||||
|       return r | ||||
|     })) | ||||
|   ).mapD(list => [].concat(...list.map(f => f.features))) | ||||
| 
 | ||||
|   let overview = allData.mapD(data => | ||||
|     ChangesetsOverview.fromDirtyData(data) | ||||
|       .filter((cs) => filteredLayer.isShown(<any>cs.properties)), [filteredLayer.currentFilter]) | ||||
| 
 | ||||
|   const trs = layer.tagRenderings.filter( | ||||
|     (tr) => tr.mappings?.length > 0 || tr.freeform?.key !== undefined | ||||
|   ).filter(tr => tr.question !== undefined) | ||||
| 
 | ||||
|   let diffInDays = overview.mapD(overview => { | ||||
|     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) | ||||
|     return (maxdate - mindate) / (1000 * 60 * 60 * 24) | ||||
| 
 | ||||
|   }) | ||||
| 
 | ||||
|   function offerAsDownload(){ | ||||
|       const data = GeoOperations.toCSV($overview._meta, { | ||||
|         ignoreTags: | ||||
|           /^((deletion:node)|(import:node)|(move:node)|(soft-delete:))/, | ||||
|       }) | ||||
|       Utils.offerContentsAsDownloadableFile(data, "statistics.csv", { | ||||
|         mimetype: "text/csv", | ||||
|       }) | ||||
|   } | ||||
| </script> | ||||
| 
 | ||||
| {#if downloaded < paths.length} | ||||
|   <Loading>Loaded {downloaded} out of {paths.length}</Loading> | ||||
| {:else} | ||||
|   <AccordionSingle> | ||||
|     <span slot="header">Filters</span> | ||||
|     <Filterview {filteredLayer} state={undefined} showLayerTitle={false} /> | ||||
|   </AccordionSingle> | ||||
|   <Accordion> | ||||
|     {#each trs as tr} | ||||
|       <AccordionItem paddingDefault="p-0" inactiveClass="text-black"> | ||||
|     <span slot="header" class={"w-full p-2 text-base"}> | ||||
|       {tr.question ?? tr.id} | ||||
|     </span> | ||||
|         <SingleStat {tr} overview={$overview} diffInDays={$diffInDays} /> | ||||
|       </AccordionItem> | ||||
|     {/each} | ||||
|   </Accordion> | ||||
|   <button on:click={() => offerAsDownload()}> | ||||
|     <DownloadIcon class="w-6 h-6" /> | ||||
|     Download as CSV | ||||
|   </button> | ||||
| {/if} | ||||
							
								
								
									
										139
									
								
								src/UI/Statistics/ChangesetsOverview.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								src/UI/Statistics/ChangesetsOverview.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,139 @@ | |||
| import { Utils } from "../../Utils" | ||||
| import { Feature, Polygon } from "geojson" | ||||
| import { OsmFeature } from "../../Models/OsmFeature" | ||||
| export interface ChangeSetData extends Feature<Polygon> { | ||||
|     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 | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export 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 static readonly valuesToSum: ReadonlyArray<string> = [ | ||||
|         "create", | ||||
|         "modify", | ||||
|         "delete", | ||||
|         "answer", | ||||
|         "move", | ||||
|         "deletion", | ||||
|         "add-image", | ||||
|         "plantnet-ai-detection", | ||||
|         "import", | ||||
|         "conflation", | ||||
|         "link-image", | ||||
|         "soft-delete", | ||||
|     ] | ||||
|     public readonly _meta: (ChangeSetData & OsmFeature)[] | ||||
| 
 | ||||
|     private constructor(meta: (ChangeSetData & OsmFeature)[]) { | ||||
|         this._meta = Utils.NoNull(meta) | ||||
|     } | ||||
| 
 | ||||
|     public static fromDirtyData(meta: (ChangeSetData & OsmFeature)[]): ChangesetsOverview { | ||||
|         return new ChangesetsOverview(meta?.map((cs) => ChangesetsOverview.cleanChangesetData(cs))) | ||||
|     } | ||||
| 
 | ||||
|     private static cleanChangesetData(cs: ChangeSetData & OsmFeature): (ChangeSetData & OsmFeature) { | ||||
|         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) { | ||||
|             // pass
 | ||||
|         } | ||||
|         return cs | ||||
|     } | ||||
| 
 | ||||
|     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 | ||||
|     } | ||||
| } | ||||
							
								
								
									
										56
									
								
								src/UI/Statistics/SingleStat.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/UI/Statistics/SingleStat.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,56 @@ | |||
| <script lang="ts"> | ||||
| 
 | ||||
|   /** | ||||
|    * Shows the statistics for a single item | ||||
|    */ | ||||
|   import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig" | ||||
|   import ToSvelte from "../Base/ToSvelte.svelte" | ||||
|   import TagRenderingChart, { StackedRenderingChart } from "../BigComponents/TagRenderingChart" | ||||
|   import { ChangesetsOverview } from "./ChangesetsOverview" | ||||
| 
 | ||||
|   export let overview: ChangesetsOverview | ||||
|   export let diffInDays: number | ||||
|   export let tr: TagRenderingConfig | ||||
| 
 | ||||
|   let total: number = undefined | ||||
|   if (tr.freeform?.key !== undefined) { | ||||
|     total = new Set( | ||||
|       overview._meta.map((f) => f.properties[tr.freeform.key]) | ||||
|     ).size | ||||
|   } | ||||
| </script> | ||||
| 
 | ||||
| {#if total > 1} | ||||
|   {total} unique values | ||||
| {/if} | ||||
| <h3>By number of changesets</h3> | ||||
| 
 | ||||
| <div class="flex"> | ||||
| 
 | ||||
|   <ToSvelte construct={ new TagRenderingChart(overview._meta, tr, { | ||||
|   groupToOtherCutoff: | ||||
|   total > 50 ? 25 : total > 10 ? 3 : 0, | ||||
|   chartstyle: "width: 24rem; height: 24rem", | ||||
|   chartType: "doughnut", | ||||
|   sort: true, | ||||
| })} /> | ||||
| </div> | ||||
| 
 | ||||
| 
 | ||||
| <ToSvelte construct={new StackedRenderingChart(tr, overview._meta, { | ||||
|   period: diffInDays <= 367 ? "day" : "month", | ||||
|   groupToOtherCutoff: | ||||
|   total > 50 ? 25 : total > 10 ? 3 : 0, | ||||
| })} /> | ||||
| 
 | ||||
| 
 | ||||
| <h3>By number of modifications</h3> | ||||
| <ToSvelte construct={    new StackedRenderingChart(    tr,    overview._meta, | ||||
|       { | ||||
|         period: diffInDays <= 367 ? "day" : "month", | ||||
|         groupToOtherCutoff: total > 50 ? 10 : 0, | ||||
|         sumFields: ChangesetsOverview. valuesToSum, | ||||
|       } | ||||
|      )} /> | ||||
| 
 | ||||
| 
 | ||||
							
								
								
									
										30
									
								
								src/UI/Statistics/StatisticsGui.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/UI/Statistics/StatisticsGui.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,30 @@ | |||
| <script lang="ts"> | ||||
|   import { UIEventSource } from "../../Logic/UIEventSource" | ||||
|   import { Utils } from "../../Utils" | ||||
|   import Loading from "../Base/Loading.svelte" | ||||
|   import AllStats from "./AllStats.svelte" | ||||
| 
 | ||||
|   let homeUrl = | ||||
|     "https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/changeset-metadata/" | ||||
|   let stats_files = "file-overview.json" | ||||
| 
 | ||||
|   let indexFile = UIEventSource.FromPromise( | ||||
|     Utils.downloadJson<string[]>(homeUrl + stats_files) | ||||
|   ) | ||||
| 
 | ||||
| </script> | ||||
| 
 | ||||
| <div class="m-4"> | ||||
|   <div class="flex justify-between"> | ||||
| 
 | ||||
|     <h2>Statistics of changes made with MapComplete</h2> | ||||
|     <a href="/" class="button">Back to index</a> | ||||
|   </div> | ||||
|   {#if $indexFile === undefined} | ||||
|     <Loading>Loading index file...</Loading> | ||||
|   {:else} | ||||
|     <AllStats paths={$indexFile.filter(p => p.startsWith("stats")).map(p => homeUrl+"/"+p)} /> | ||||
|   {/if} | ||||
| 
 | ||||
| </div> | ||||
| 
 | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue