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
|
@ -19,6 +19,7 @@
|
|||
export let filteredLayer: FilteredLayer
|
||||
export let highlightedLayer: Store<string | undefined> = new ImmutableStore(undefined)
|
||||
export let zoomlevel: Store<number> = new ImmutableStore(22)
|
||||
export let showLayerTitle = true
|
||||
let layer: LayerConfig = filteredLayer.layerDef
|
||||
let isDisplayed: UIEventSource<boolean> = filteredLayer.isDisplayed
|
||||
|
||||
|
@ -54,6 +55,7 @@
|
|||
|
||||
{#if filteredLayer.layerDef.name}
|
||||
<div class:focus={$highlightedLayer === filteredLayer.layerDef.id} class="mb-1.5">
|
||||
{#if showLayerTitle}
|
||||
<Checkbox selected={isDisplayed}>
|
||||
<div class="no-image-background block h-6 w-6" class:opacity-50={!$isDisplayed}>
|
||||
<ToSvelte construct={() => layer.defaultIcon()} />
|
||||
|
@ -67,6 +69,7 @@
|
|||
</span>
|
||||
{/if}
|
||||
</Checkbox>
|
||||
{/if}
|
||||
|
||||
{#if $isDisplayed && filteredLayer.layerDef.filters?.length > 0}
|
||||
<div id="subfilters" class="ml-4 flex flex-col gap-y-1">
|
||||
|
|
|
@ -21,7 +21,7 @@ export class StackedRenderingChart extends ChartJs {
|
|||
period: "day" | "month"
|
||||
groupToOtherCutoff?: 3 | number
|
||||
// If given, take the sum of these fields to get the feature weight
|
||||
sumFields?: string[]
|
||||
sumFields?: ReadonlyArray<string>
|
||||
hideUnknown?: boolean
|
||||
hideNotApplicable?: boolean
|
||||
}
|
||||
|
@ -57,9 +57,9 @@ export class StackedRenderingChart extends ChartJs {
|
|||
backgroundColor: string
|
||||
}[] = []
|
||||
const allDays = StackedRenderingChart.getAllDays(features)
|
||||
let trimmedDays = allDays.map((d) => d.substr(0, 10))
|
||||
let trimmedDays = allDays.map((d) => d.substring(0, 10))
|
||||
if (options?.period === "month") {
|
||||
trimmedDays = trimmedDays.map((d) => d.substr(0, 7))
|
||||
trimmedDays = trimmedDays.map((d) => d.substring(0, 7))
|
||||
}
|
||||
trimmedDays = Utils.Dedup(trimmedDays)
|
||||
|
||||
|
@ -156,7 +156,7 @@ export class StackedRenderingChart extends ChartJs {
|
|||
): string[] {
|
||||
let earliest: Date = undefined
|
||||
let latest: Date = undefined
|
||||
let allDates = new Set<string>()
|
||||
const allDates = new Set<string>()
|
||||
features.forEach((value) => {
|
||||
const d = new Date(value.properties.date)
|
||||
Utils.SetMidnight(d)
|
||||
|
@ -290,10 +290,10 @@ export default class TagRenderingChart extends Combine {
|
|||
const mappings = tagRendering.mappings ?? []
|
||||
|
||||
options = options ?? {}
|
||||
let unknownCount: T[] = []
|
||||
const categoryCounts: T[][] = mappings.map((_) => [])
|
||||
const unknownCount: T[] = []
|
||||
const categoryCounts: T[][] = mappings.map(() => [])
|
||||
const otherCounts: Record<string, T[]> = {}
|
||||
let notApplicable: T[] = []
|
||||
const notApplicable: T[] = []
|
||||
for (const feature of features) {
|
||||
const props = feature.properties
|
||||
if (
|
||||
|
@ -346,7 +346,7 @@ export default class TagRenderingChart extends Combine {
|
|||
return { labels: undefined, data: undefined }
|
||||
}
|
||||
|
||||
let otherGrouped: T[] = []
|
||||
const otherGrouped: T[] = []
|
||||
const otherLabels: string[] = []
|
||||
const otherData: T[][] = []
|
||||
const sortedOtherCounts: [string, T[]][] = []
|
||||
|
|
|
@ -18,19 +18,3 @@ export default class Toggle extends VariableUiElement {
|
|||
this.isEnabled = isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as `Toggle`, but will swap on click
|
||||
*/
|
||||
export class ClickableToggle extends Toggle {
|
||||
public declare readonly isEnabled: UIEventSource<boolean>
|
||||
|
||||
constructor(
|
||||
showEnabled: string | BaseUIElement,
|
||||
showDisabled: string | BaseUIElement,
|
||||
isEnabled: UIEventSource<boolean> = new UIEventSource<boolean>(false)
|
||||
) {
|
||||
super(showEnabled, showDisabled, isEnabled)
|
||||
this.isEnabled = isEnabled
|
||||
}
|
||||
}
|
||||
|
|
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>
|
||||
|
|
@ -1,358 +1,4 @@
|
|||
/**
|
||||
* 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 TagRenderingChart, { StackedRenderingChart } from "./BigComponents/TagRenderingChart"
|
||||
import BaseUIElement from "./BaseUIElement"
|
||||
import Title from "./Base/Title"
|
||||
import { FixedUiElement } from "./Base/FixedUiElement"
|
||||
import List from "./Base/List"
|
||||
import ThemeConfig from "../Models/ThemeConfig/ThemeConfig"
|
||||
import mcChanges from "../../src/assets/generated/themes/mapcomplete-changes.json"
|
||||
import SvelteUIElement from "./Base/SvelteUIElement"
|
||||
import Filterview from "./BigComponents/Filterview.svelte"
|
||||
import FilteredLayer from "../Models/FilteredLayer"
|
||||
import { SubtleButton } from "./Base/SubtleButton"
|
||||
import { GeoOperations } from "../Logic/GeoOperations"
|
||||
import { FeatureCollection, Polygon } from "geojson"
|
||||
import { Feature } from "geojson"
|
||||
import { default as StatisticsSvelte } from "../UI/Statistics/StatisticsGui.svelte"
|
||||
|
||||
class StatsticsForOverviewFile extends Combine {
|
||||
constructor(homeUrl: string, paths: string[]) {
|
||||
paths = paths.filter((p) => !p.endsWith("file-overview.json"))
|
||||
const layer = new ThemeConfig(<any>mcChanges, true).layers[0]
|
||||
const filteredLayer = new FilteredLayer(layer)
|
||||
const filterPanel = new Combine([
|
||||
new Title("Filters"),
|
||||
new SvelteUIElement(Filterview, { filteredLayer }),
|
||||
])
|
||||
filteredLayer.currentFilter.addCallbackAndRun((tf) => {
|
||||
console.log("Filters are", tf)
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
const overview = ChangesetsOverview.fromDirtyData(
|
||||
[].concat(...downloaded.map((d) => d.features))
|
||||
).filter((cs) => filteredLayer.isShown(<any>cs.properties))
|
||||
console.log("Overview is", overview)
|
||||
|
||||
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) {
|
||||
if (tr.question === undefined) {
|
||||
continue
|
||||
}
|
||||
console.log(tr)
|
||||
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 Title("By number of changesets", 4).SetClass("p-2"),
|
||||
new StackedRenderingChart(tr, <any>overview._meta, {
|
||||
period: diffInDays <= 367 ? "day" : "month",
|
||||
groupToOtherCutoff:
|
||||
total > 50 ? 25 : total > 10 ? 3 : 0,
|
||||
}).SetStyle("width: 75%; height: 600px"),
|
||||
new TagRenderingChart(<any>overview._meta, tr, {
|
||||
groupToOtherCutoff:
|
||||
total > 50 ? 25 : total > 10 ? 3 : 0,
|
||||
chartType: "doughnut",
|
||||
chartclasses: "w-8 h-8",
|
||||
sort: true,
|
||||
}).SetStyle("width: 25rem"),
|
||||
new Title("By number of modifications", 4).SetClass("p-2"),
|
||||
new StackedRenderingChart(
|
||||
tr,
|
||||
<any>Utils.Clone(overview._meta),
|
||||
{
|
||||
period: diffInDays <= 367 ? "day" : "month",
|
||||
groupToOtherCutoff: total > 50 ? 10 : 0,
|
||||
sumFields: valuesToSum,
|
||||
}
|
||||
).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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
elements.push(
|
||||
new SubtleButton(undefined, "Download as csv").onClick(() => {
|
||||
const data = GeoOperations.toCSV(overview._meta, {
|
||||
ignoreTags:
|
||||
/^((deletion:node)|(import:node)|(move:node)|(soft-delete:))/,
|
||||
})
|
||||
Utils.offerContentsAsDownloadableFile(data, "statistics.csv", {
|
||||
mimetype: "text/csv",
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
return new Combine(elements)
|
||||
},
|
||||
[filteredLayer.currentFilter]
|
||||
)
|
||||
).SetClass("block w-full h-full"),
|
||||
])
|
||||
this.SetClass("block w-full h-full")
|
||||
}
|
||||
}
|
||||
|
||||
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 StatsticsForOverviewFile(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[]
|
||||
|
||||
private constructor(meta: ChangeSetData[]) {
|
||||
this._meta = Utils.NoNull(meta)
|
||||
}
|
||||
|
||||
public static fromDirtyData(meta: ChangeSetData[]): ChangesetsOverview {
|
||||
return new ChangesetsOverview(meta?.map((cs) => ChangesetsOverview.cleanChangesetData(cs)))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
new StatisticsGUI().AttachTo("main")
|
||||
new SvelteUIElement(StatisticsSvelte).AttachTo("main")
|
||||
|
|
Loading…
Reference in a new issue