Add charts to dashboard view
This commit is contained in:
parent
47a184d626
commit
72f7bbd7db
8 changed files with 314 additions and 76 deletions
24
UI/Base/ChartJs.ts
Normal file
24
UI/Base/ChartJs.ts
Normal file
|
@ -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<TType>,
|
||||
TLabel = unknown
|
||||
> extends BaseUIElement{
|
||||
private readonly _config: ChartConfiguration<TType, TData, TLabel>;
|
||||
|
||||
constructor(config: ChartConfiguration<TType, TData, TLabel>) {
|
||||
super();
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
protected InnerConstructElement(): HTMLElement {
|
||||
const canvas = document.createElement("canvas");
|
||||
new Chart(canvas, this._config);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -33,6 +33,7 @@ export class VariableUiElement extends BaseUIElement {
|
|||
if (self.isDestroyed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
while (el.firstChild) {
|
||||
el.removeChild(el.lastChild);
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ export default abstract class BaseUIElement {
|
|||
|
||||
protected _constructedHtmlElement: HTMLElement;
|
||||
protected isDestroyed = false;
|
||||
private clss: Set<string> = new Set<string>();
|
||||
private readonly clss: Set<string> = new Set<string>();
|
||||
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) {
|
||||
|
|
146
UI/BigComponents/TagRenderingChart.ts
Normal file
146
UI/BigComponents/TagRenderingChart.ts
Normal file
|
@ -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 = <ChartConfiguration>{
|
||||
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")
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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")))
|
||||
|
|
|
@ -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);
|
||||
|
|
120
test.ts
120
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[] = <any>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<string, string>, searchTerms?: Record<string, string[]> } {
|
||||
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<OsmFeature[]>([
|
||||
{
|
||||
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")
|
||||
new Combine(food.tagRenderings.map(tr => new TagRenderingChart(data, new TagRenderingConfig(tr, "test"), {chartclasses: "w-160 h-160"})))
|
||||
.AttachTo("maindiv")
|
Loading…
Reference in a new issue