diff --git a/UI/Base/ChartJs.ts b/UI/Base/ChartJs.ts new file mode 100644 index 000000000..a4250f4c4 --- /dev/null +++ b/UI/Base/ChartJs.ts @@ -0,0 +1,24 @@ +import BaseUIElement from "../BaseUIElement"; +import {Chart, ChartConfiguration, ChartType, DefaultDataPoint, registerables} from 'chart.js'; +Chart.register(...registerables); + + +export default class ChartJs< + TType extends ChartType = ChartType, + TData = DefaultDataPoint, + TLabel = unknown + > extends BaseUIElement{ + private readonly _config: ChartConfiguration; + + constructor(config: ChartConfiguration) { + super(); + this._config = config; + } + + protected InnerConstructElement(): HTMLElement { + const canvas = document.createElement("canvas"); + new Chart(canvas, this._config); + return canvas; + } + +} \ No newline at end of file diff --git a/UI/Base/Combine.ts b/UI/Base/Combine.ts index 87eb30f5b..05ad8de84 100644 --- a/UI/Base/Combine.ts +++ b/UI/Base/Combine.ts @@ -38,6 +38,9 @@ export default class Combine extends BaseUIElement { protected InnerConstructElement(): HTMLElement { const el = document.createElement("span") try { + if(this.uiElements === undefined){ + console.error("PANIC") + } for (const subEl of this.uiElements) { if (subEl === undefined || subEl === null) { continue; diff --git a/UI/Base/VariableUIElement.ts b/UI/Base/VariableUIElement.ts index 1dbbe3ded..3163ac39b 100644 --- a/UI/Base/VariableUIElement.ts +++ b/UI/Base/VariableUIElement.ts @@ -33,6 +33,7 @@ export class VariableUiElement extends BaseUIElement { if (self.isDestroyed) { return true; } + while (el.firstChild) { el.removeChild(el.lastChild); } diff --git a/UI/BaseUIElement.ts b/UI/BaseUIElement.ts index 556ab637f..749f61d35 100644 --- a/UI/BaseUIElement.ts +++ b/UI/BaseUIElement.ts @@ -9,7 +9,7 @@ export default abstract class BaseUIElement { protected _constructedHtmlElement: HTMLElement; protected isDestroyed = false; - private clss: Set = new Set(); + private readonly clss: Set = new Set(); private style: string; private _onClick: () => void; @@ -114,7 +114,7 @@ export default abstract class BaseUIElement { if (style !== undefined && style !== "") { el.style.cssText = style } - if (this.clss.size > 0) { + if (this.clss?.size > 0) { try { el.classList.add(...Array.from(this.clss)) } catch (e) { diff --git a/UI/BigComponents/TagRenderingChart.ts b/UI/BigComponents/TagRenderingChart.ts new file mode 100644 index 000000000..54d0d861c --- /dev/null +++ b/UI/BigComponents/TagRenderingChart.ts @@ -0,0 +1,146 @@ +import ChartJs from "../Base/ChartJs"; +import {OsmFeature} from "../../Models/OsmFeature"; +import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"; +import {ChartConfiguration} from 'chart.js'; +import Combine from "../Base/Combine"; + +export default class TagRenderingChart extends Combine { + + private static readonly unkownColor = 'rgba(128, 128, 128, 0.2)' + private static readonly unkownBorderColor = 'rgba(128, 128, 128, 0.2)' + + private static readonly otherColor = 'rgba(128, 128, 128, 0.2)' + private static readonly otherBorderColor = 'rgba(128, 128, 255)' + private static readonly notApplicableColor = 'rgba(128, 128, 128, 0.2)' + private static readonly notApplicableBorderColor = 'rgba(255, 0, 0)' + + + private static readonly backgroundColors = [ + 'rgba(255, 99, 132, 0.2)', + 'rgba(54, 162, 235, 0.2)', + 'rgba(255, 206, 86, 0.2)', + 'rgba(75, 192, 192, 0.2)', + 'rgba(153, 102, 255, 0.2)', + 'rgba(255, 159, 64, 0.2)' + ] + + private static readonly borderColors = [ + 'rgba(255, 99, 132, 1)', + 'rgba(54, 162, 235, 1)', + 'rgba(255, 206, 86, 1)', + 'rgba(75, 192, 192, 1)', + 'rgba(153, 102, 255, 1)', + 'rgba(255, 159, 64, 1)' + ] + + /** + * Creates a chart about this tagRendering for the given data + */ + constructor(features: OsmFeature[], tagRendering: TagRenderingConfig, options?: { + chartclasses?: string, + chartstyle?: string + }) { + + const mappings = tagRendering.mappings ?? [] + if (mappings.length === 0 && tagRendering.freeform?.key === undefined) { + super(["TagRendering", tagRendering.id, "does not have mapping or a freeform key - no stats can be made"]) + return; + } + let unknownCount = 0; + let categoryCounts = mappings.map(_ => 0) + let otherCount = 0; + let notApplicable = 0; + for (const feature of features) { + const props = feature.properties + if(tagRendering.condition !== undefined && !tagRendering.condition.matchesProperties(props)){ + notApplicable++; + continue; + } + + if (!tagRendering.IsKnown(props)) { + unknownCount++; + continue; + } + let foundMatchingMapping = false; + for (let i = 0; i < mappings.length; i++) { + const mapping = mappings[i]; + if (mapping.if.matchesProperties(props)) { + categoryCounts[i]++ + foundMatchingMapping = true + if (!tagRendering.multiAnswer) { + break; + } + } + } + if (tagRendering.freeform?.key !== undefined && props[tagRendering.freeform.key] !== undefined) { + otherCount++ + } else if (!foundMatchingMapping) { + unknownCount++ + } + } + + if (unknownCount + notApplicable === features.length) { + console.log("Totals:", features.length+" elements","tr:", tagRendering, "other",otherCount, "unkown",unknownCount, "na", notApplicable) + super(["No relevant data for ", tagRendering.id]) + return + } + + const labels = ["Unknown", "Other", "Not applicable", ...mappings?.map(m => m.then.txt) ?? []] + const data = [unknownCount, otherCount, notApplicable,...categoryCounts] + const borderColor = [TagRenderingChart.unkownBorderColor, TagRenderingChart.otherBorderColor, TagRenderingChart.notApplicableBorderColor] + const backgroundColor = [TagRenderingChart.unkownColor, TagRenderingChart.otherColor, TagRenderingChart.notApplicableColor] + + while (borderColor.length < data.length) { + borderColor.push(...TagRenderingChart.borderColors) + backgroundColor.push(...TagRenderingChart.backgroundColors) + } + + for (let i = data.length; i >= 0; i--) { + if (data[i] === 0) { + labels.splice(i, 1) + data.splice(i, 1) + borderColor.splice(i, 1) + backgroundColor.splice(i, 1) + } + } + + if (tagRendering.id === undefined) { + console.log(tagRendering) + } + const config = { + type: tagRendering.multiAnswer ? 'bar' : 'doughnut', + data: { + labels, + datasets: [{ + data, + backgroundColor, + borderColor, + borderWidth: 1, + label: undefined + }] + }, + options: { + plugins: { + legend: { + display: !tagRendering.multiAnswer + } + } + } + } + + const chart = new ChartJs(config).SetClass(options?.chartclasses ?? "w-32 h-32"); + + if (options.chartstyle !== undefined) { + chart.SetStyle(options.chartstyle) + } + + + super([ + tagRendering.question ?? tagRendering.id, + chart]) + + this.SetClass("block") + } + + +} \ No newline at end of file diff --git a/UI/DashboardGui.ts b/UI/DashboardGui.ts index acf0ea5a1..1256c6fde 100644 --- a/UI/DashboardGui.ts +++ b/UI/DashboardGui.ts @@ -26,6 +26,8 @@ import {FilterState} from "../Models/FilteredLayer"; import Translations from "./i18n/Translations"; import Constants from "../Models/Constants"; import SimpleAddUI from "./BigComponents/SimpleAddUI"; +import TagRenderingChart from "./BigComponents/TagRenderingChart"; +import Loading from "./Base/Loading"; export default class DashboardGui { @@ -170,7 +172,7 @@ export default class DashboardGui { } const map = this.SetupMap(); - Utils.downloadJson("./service-worker-version").then(data => console.log("Service worker", data)).catch(e => console.log("Service worker not active")) + Utils.downloadJson("./service-worker-version").then(data => console.log("Service worker", data)).catch(_ => console.log("Service worker not active")) document.getElementById("centermessage").classList.add("hidden") @@ -180,7 +182,7 @@ export default class DashboardGui { } const self = this; - const elementsInview = new UIEventSource([]); + const elementsInview = new UIEventSource<{ distance: number, center: [number, number], element: OsmFeature, layer: LayerConfig }[]>([]); function update() { elementsInview.setData(self.visibleElements(map, layers)) @@ -201,10 +203,10 @@ export default class DashboardGui { const welcome = new Combine([state.layoutToUse.description, state.layoutToUse.descriptionTail]) self.currentView.setData({title: state.layoutToUse.title, contents: welcome}) const filterViewIsOpened = new UIEventSource(false) - filterViewIsOpened.addCallback(fv => self.currentView.setData({title: "filters", contents: filterView})) - + filterViewIsOpened.addCallback(_ => self.currentView.setData({title: "filters", contents: filterView})) + const newPointIsShown = new UIEventSource(false); - const addNewPoint = new SimpleAddUI( + const addNewPoint = new SimpleAddUI( new UIEventSource(true), new UIEventSource(undefined), filterViewIsOpened, @@ -213,20 +215,50 @@ export default class DashboardGui { ); const addNewPointTitle = "Add a missing point" this.currentView.addCallbackAndRunD(cv => { - newPointIsShown.setData(cv.contents === addNewPoint) + newPointIsShown.setData(cv.contents === addNewPoint) }) newPointIsShown.addCallbackAndRun(isShown => { - if(isShown){ - if(self.currentView.data.contents !== addNewPoint){ + if (isShown) { + if (self.currentView.data.contents !== addNewPoint) { self.currentView.setData({title: addNewPointTitle, contents: addNewPoint}) } - }else{ - if(self.currentView.data.contents === addNewPoint){ + } else { + if (self.currentView.data.contents === addNewPoint) { self.currentView.setData(undefined) } } }) - + + const statistics = + new VariableUiElement(elementsInview.stabilized(1000).map(features => { + if (features === undefined) { + return new Loading("Loading data") + } + if (features.length === 0) { + return "No elements in view" + } + const els = [] + for (const layer of state.layoutToUse.layers) { + if(layer.name === undefined){ + continue + } + const featuresForLayer = features.filter(f => f.layer === layer).map(f => f.element) + if(featuresForLayer.length === 0){ + continue + } + els.push(new Title(layer.name)) + for (const tagRendering of layer.tagRenderings) { + const chart = new TagRenderingChart(featuresForLayer, tagRendering, { + chartclasses: "w-full", + chartstyle: "height: 60rem" + }) + els.push(chart) + } + } + return new Combine(els) + })) + + new Combine([ new Combine([ this.viewSelector(new Title(state.layoutToUse.title.Clone(), 2), state.layoutToUse.title.Clone(), welcome, "welcome"), @@ -235,12 +267,12 @@ export default class DashboardGui { this.viewSelector(new Title( new VariableUiElement(elementsInview.map(elements => "There are " + elements?.length + " elements in view"))), "Statistics", - new FixedUiElement("Stats"), "statistics"), + statistics, "statistics"), this.viewSelector(new FixedUiElement("Filter"), "Filters", filterView, "filters"), - this.viewSelector(new Combine([ "Add a missing point"]), addNewPointTitle, - addNewPoint + this.viewSelector(new Combine(["Add a missing point"]), addNewPointTitle, + addNewPoint ), new VariableUiElement(elementsInview.map(elements => this.mainElementsView(elements).SetClass("block m-2"))) diff --git a/css/index-tailwind-output.css b/css/index-tailwind-output.css index 4065ece84..2419d3ccd 100644 --- a/css/index-tailwind-output.css +++ b/css/index-tailwind-output.css @@ -1050,8 +1050,8 @@ video { height: 6rem; } -.h-8 { - height: 2rem; +.h-80 { + height: 20rem; } .h-64 { @@ -1070,6 +1070,10 @@ video { height: 3rem; } +.h-8 { + height: 2rem; +} + .h-4 { height: 1rem; } @@ -1134,12 +1138,8 @@ video { width: 100%; } -.w-8 { - width: 2rem; -} - -.w-1 { - width: 0.25rem; +.w-80 { + width: 20rem; } .w-24 { @@ -1162,6 +1162,10 @@ video { width: 3rem; } +.w-8 { + width: 2rem; +} + .w-4 { width: 1rem; } @@ -1570,10 +1574,6 @@ video { padding-right: 1rem; } -.pr-2 { - padding-right: 0.5rem; -} - .pl-1 { padding-left: 0.25rem; } @@ -1642,6 +1642,10 @@ video { padding-top: 0.125rem; } +.pr-2 { + padding-right: 0.5rem; +} + .pl-6 { padding-left: 1.5rem; } @@ -1860,6 +1864,10 @@ video { z-index: 10001 } +.w-160 { + width: 40rem; +} + .bg-subtle { background-color: var(--subtle-detail-color); color: var(--subtle-detail-color-contrast); diff --git a/test.ts b/test.ts index e25a2ad54..f9feaf0fa 100644 --- a/test.ts +++ b/test.ts @@ -1,52 +1,76 @@ -import * as shops from "./assets/generated/layers/shops.json" -import Combine from "./UI/Base/Combine"; -import Img from "./UI/Base/Img"; -import BaseUIElement from "./UI/BaseUIElement"; -import {VariableUiElement} from "./UI/Base/VariableUIElement"; -import LanguagePicker from "./UI/LanguagePicker"; -import TagRenderingConfig, {Mapping} from "./Models/ThemeConfig/TagRenderingConfig"; -import {MappingConfigJson} from "./Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson"; -import {FixedUiElement} from "./UI/Base/FixedUiElement"; -import {TagsFilter} from "./Logic/Tags/TagsFilter"; -import {SearchablePillsSelector} from "./UI/Input/SearchableMappingsSelector"; +import ChartJs from "./UI/Base/ChartJs"; +import TagRenderingChart from "./UI/BigComponents/TagRenderingChart"; +import {OsmFeature} from "./Models/OsmFeature"; +import * as food from "./assets/generated/layers/food.json" +import TagRenderingConfig from "./Models/ThemeConfig/TagRenderingConfig"; import {UIEventSource} from "./Logic/UIEventSource"; - -const mappingsRaw: MappingConfigJson[] = shops.tagRenderings.find(tr => tr.id == "shop_types").mappings -const mappings = mappingsRaw.map((m, i) => TagRenderingConfig.ExtractMapping(m, i, "test", "test")) - -function fromMapping(m: Mapping): { show: BaseUIElement, value: TagsFilter, mainTerm: Record, searchTerms?: Record } { - const el: BaseUIElement = m.then - let icon: BaseUIElement - if (m.icon !== undefined) { - icon = new Img(m.icon).SetClass("h-8 w-8 pr-2") - } else { - icon = new FixedUiElement("").SetClass("h-8 w-1") - } - const show = new Combine([ - icon, - el.SetClass("block-ruby") - ]).SetClass("flex items-center") - - return {show, mainTerm: m.then.translations, searchTerms: m.searchTerms, value: m.if}; - -} -const search = new UIEventSource("") -const sp = new SearchablePillsSelector( - mappings.map(m => fromMapping(m)), +import Combine from "./UI/Base/Combine"; +const data = new UIEventSource([ { - noMatchFound: new VariableUiElement(search.map(s => "Mark this a `"+s+"`")), - onNoSearch: new FixedUiElement("Search in "+mappingsRaw.length+" categories"), - selectIfSingle: true, - searchValue: search + properties: { + id: "node/1234", + cuisine:"pizza", + "payment:cash":"yes" + }, + geometry:{ + type: "Point", + coordinates: [0,0] + }, + id: "node/1234", + type: "Feature" + }, + { + properties: { + id: "node/42", + cuisine:"pizza", + "payment:cash":"yes" + }, + geometry:{ + type: "Point", + coordinates: [1,0] + }, + id: "node/42", + type: "Feature" + }, + { + properties: { + id: "node/452", + cuisine:"pasta", + "payment:cash":"yes", + "payment:cards":"yes" + }, + geometry:{ + type: "Point", + coordinates: [2,0] + }, + id: "node/452", + type: "Feature" + }, + { + properties: { + id: "node/4542", + cuisine:"something_comletely_invented", + "payment:cards":"yes" + }, + geometry:{ + type: "Point", + coordinates: [3,0] + }, + id: "node/4542", + type: "Feature" + }, + { + properties: { + id: "node/45425", + }, + geometry:{ + type: "Point", + coordinates: [3,0] + }, + id: "node/45425", + type: "Feature" } -) +]); -sp.AttachTo("maindiv") - -const lp = new LanguagePicker(["en", "nl"], "") - -new Combine([ - new VariableUiElement(sp.GetValue().map(tf => new FixedUiElement("Selected tags: " + tf.map(tf => tf.asHumanString(false, false, {})).join(", ")))), - lp -]).SetClass("flex flex-col") - .AttachTo("extradiv") \ No newline at end of file +new Combine(food.tagRenderings.map(tr => new TagRenderingChart(data, new TagRenderingConfig(tr, "test"), {chartclasses: "w-160 h-160"}))) + .AttachTo("maindiv") \ No newline at end of file