From 1d48d935ba8943defacdbb17c1d18ece48f2900b Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Sat, 12 Jul 2025 14:40:08 +0200 Subject: [PATCH] Refactoring: port ChartJS to Svelte --- public/css/index-tailwind-output.css | 4 + src/UI/Base/ChartJs.svelte | 21 + src/UI/Base/ChartJs.ts | 66 --- .../ChartJsUtils.ts} | 509 +++++++++--------- src/UI/History/AggregateView.svelte | 12 +- src/UI/Statistics/LayerStatistics.svelte | 32 +- src/UI/Statistics/SingleStat.svelte | 28 +- 7 files changed, 333 insertions(+), 339 deletions(-) create mode 100644 src/UI/Base/ChartJs.svelte delete mode 100644 src/UI/Base/ChartJs.ts rename src/UI/{BigComponents/TagRenderingChart.ts => Base/ChartJsUtils.ts} (50%) diff --git a/public/css/index-tailwind-output.css b/public/css/index-tailwind-output.css index 58eebde15..8612c8f83 100644 --- a/public/css/index-tailwind-output.css +++ b/public/css/index-tailwind-output.css @@ -2504,6 +2504,10 @@ input[type="range"].range-lg::-moz-range-thumb { flex-wrap: wrap-reverse; } +.place-content-center { + place-content: center; +} + .items-start { align-items: flex-start; } diff --git a/src/UI/Base/ChartJs.svelte b/src/UI/Base/ChartJs.svelte new file mode 100644 index 000000000..99353168c --- /dev/null +++ b/src/UI/Base/ChartJs.svelte @@ -0,0 +1,21 @@ + + +{#if config} + +{:else} + +{/if} diff --git a/src/UI/Base/ChartJs.ts b/src/UI/Base/ChartJs.ts deleted file mode 100644 index 138eca609..000000000 --- a/src/UI/Base/ChartJs.ts +++ /dev/null @@ -1,66 +0,0 @@ -import BaseUIElement from "../BaseUIElement" -import { Chart, ChartConfiguration, ChartType, DefaultDataPoint, registerables } from "chart.js" - -Chart?.register(...(registerables ?? [])) - -export class ChartJsColours { - public static readonly unknownColor = "rgba(128, 128, 128, 0.2)" - public static readonly unknownBorderColor = "rgba(128, 128, 128, 0.2)" - - public static readonly otherColor = "rgba(128, 128, 128, 0.2)" - public static readonly otherBorderColor = "rgba(128, 128, 255)" - public static readonly notApplicableColor = "#fff" // "rgba(128, 128, 128, 0.2)" - public static readonly notApplicableBorderColor = "rgb(241,132,132)" - - public 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)", - ] - - public 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)", - ] -} -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") - // A bit exceptional: we apply the styles before giving them to 'chartJS' - if (this.style !== undefined) { - canvas.style.cssText = this.style - } - if (this.clss?.size > 0) { - try { - canvas.classList.add(...Array.from(this.clss)) - } catch (e) { - console.error( - "Invalid class name detected in:", - Array.from(this.clss).join(" "), - "\nErr msg is ", - e - ) - } - } - new Chart(canvas, this._config) - return canvas - } -} diff --git a/src/UI/BigComponents/TagRenderingChart.ts b/src/UI/Base/ChartJsUtils.ts similarity index 50% rename from src/UI/BigComponents/TagRenderingChart.ts rename to src/UI/Base/ChartJsUtils.ts index eeb4c7c37..292d4e4a5 100644 --- a/src/UI/BigComponents/TagRenderingChart.ts +++ b/src/UI/Base/ChartJsUtils.ts @@ -1,163 +1,59 @@ -import ChartJs, { ChartJsColours } from "../Base/ChartJs" -import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig" import { ChartConfiguration } from "chart.js" -import Combine from "../Base/Combine" +import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig" import { TagUtils } from "../../Logic/Tags/TagUtils" -import { Utils } from "../../Utils" import { OsmFeature } from "../../Models/OsmFeature" +import { Utils } from "../../Utils" +export class ChartJsColours { + public static readonly unknownColor = "rgba(128, 128, 128, 0.2)" + public static readonly unknownBorderColor = "rgba(128, 128, 128, 0.2)" + + public static readonly otherColor = "rgba(128, 128, 128, 0.2)" + public static readonly otherBorderColor = "rgba(128, 128, 255)" + public static readonly notApplicableColor = "#fff" // "rgba(128, 128, 128, 0.2)" + public static readonly notApplicableBorderColor = "rgb(241,132,132)" + + public 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)", + ] + + public 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)", + ] +} export interface TagRenderingChartOptions { groupToOtherCutoff?: 3 | number sort?: boolean hideUnkown?: boolean - hideNotApplicable?: boolean + hideNotApplicable?: boolean, + chartType?: "pie" | "bar" | "doughnut" } +export class ChartJsUtils { -export class StackedRenderingChart extends ChartJs { - constructor( - tr: TagRenderingConfig, - features: (OsmFeature & { properties: { date: string } })[], - options?: { - period: "day" | "month" - groupToOtherCutoff?: 3 | number - // If given, take the sum of these fields to get the feature weight - sumFields?: ReadonlyArray - hideUnknown?: boolean - hideNotApplicable?: boolean - } - ) { - const { labels, data } = TagRenderingChart.extractDataAndLabels(tr, features, { - sort: true, - groupToOtherCutoff: options?.groupToOtherCutoff, - hideNotApplicable: options?.hideNotApplicable, - hideUnkown: options?.hideUnknown, - }) - if (labels === undefined || data === undefined) { - console.error( - "Could not extract data and labels for ", - tr, - " with features", - features, - ": no labels or no data" - ) - throw "No labels or data given..." - } - - for (let i = labels.length; i >= 0; i--) { - if (data[i]?.length != 0) { - continue - } - data.splice(i, 1) - labels.splice(i, 1) - } - - const datasets: { - label: string /*themename*/ - data: number[] /*counts per day*/ - backgroundColor: string - }[] = [] - const allDays = StackedRenderingChart.getAllDays(features) - let trimmedDays = allDays.map((d) => d.substring(0, 10)) - if (options?.period === "month") { - trimmedDays = trimmedDays.map((d) => d.substring(0, 7)) - } - trimmedDays = Utils.Dedup(trimmedDays) - - for (let i = 0; i < labels.length; i++) { - const label = labels[i] - const changesetsForTheme = data[i] - const perDay: Record = {} - for (const changeset of changesetsForTheme) { - const csDate = new Date(changeset.properties.date) - Utils.SetMidnight(csDate) - let str = csDate.toISOString() - str = str.substr(0, 10) - if (options?.period === "month") { - str = str.substr(0, 7) - } - if (perDay[str] === undefined) { - perDay[str] = [changeset] - } else { - perDay[str].push(changeset) - } - } - - const countsPerDay: number[] = [] - for (let i = 0; i < trimmedDays.length; i++) { - const day = trimmedDays[i] - - const featuresForDay = perDay[day] - if (!featuresForDay) { - continue - } - if (options.sumFields !== undefined) { - let sum = 0 - for (const featuresForDayElement of featuresForDay) { - const props = featuresForDayElement.properties - for (const key of options.sumFields) { - if (!props[key]) { - continue - } - const v = Number(props[key]) - if (isNaN(v)) { - continue - } - sum += v - } - } - countsPerDay[i] = sum - } else { - countsPerDay[i] = featuresForDay?.length ?? 0 - } - } - let backgroundColor = - ChartJsColours.borderColors[i % ChartJsColours.borderColors.length] - if (label === "Unknown") { - backgroundColor = ChartJsColours.unknownBorderColor - } - if (label === "Other") { - backgroundColor = ChartJsColours.otherBorderColor - } - datasets.push({ - data: countsPerDay, - backgroundColor, - label, - }) - } - - const perDayData = { - labels: trimmedDays, - datasets, - } - - const config = { - type: "bar", - data: perDayData, - options: { - responsive: true, - legend: { - display: false, - }, - scales: { - x: { - stacked: true, - }, - y: { - stacked: true, - }, - }, - }, - } - super(config) - } - - public static getAllDays( + /** + * Gets the 'date' out of all features.properties, + * returns a range with all dates from 'earliest' to 'latest' as to get one continuous range + * + * Useful to use as X-axis for chartJS + * @param features + */ + private static getAllDays( features: (OsmFeature & { properties: { date: string } })[] ): string[] { let earliest: Date = undefined let latest: Date = undefined const allDates = new Set() - features.forEach((value) => { + for (const value of features) { const d = new Date(value.properties.date) Utils.SetMidnight(d) @@ -172,7 +68,7 @@ export class StackedRenderingChart extends ChartJs { latest = d } allDates.add(d.toISOString()) - }) + } while (earliest < latest) { earliest.setDate(earliest.getDate() + 1) @@ -182,100 +78,6 @@ export class StackedRenderingChart extends ChartJs { days.sort() return days } -} - -export default class TagRenderingChart extends Combine { - /** - * Creates a chart about this tagRendering for the given data - */ - constructor( - features: { properties: Record }[], - tagRendering: TagRenderingConfig, - options?: TagRenderingChartOptions & { - chartclasses?: string - chartstyle?: string - chartType?: "pie" | "bar" | "doughnut" - } - ) { - if (tagRendering.mappings?.length === 0 && tagRendering.freeform?.key === undefined) { - super([]) - this.SetClass("hidden") - return - } - - const { labels, data } = TagRenderingChart.extractDataAndLabels( - tagRendering, - features, - options - ) - if (labels === undefined || data === undefined) { - super([]) - this.SetClass("hidden") - return - } - - const borderColor = [ - ChartJsColours.unknownBorderColor, - ChartJsColours.otherBorderColor, - ChartJsColours.notApplicableBorderColor, - ] - const backgroundColor = [ - ChartJsColours.unknownColor, - ChartJsColours.otherColor, - ChartJsColours.notApplicableColor, - ] - - while (borderColor.length < data.length) { - borderColor.push(...ChartJsColours.borderColors) - backgroundColor.push(...ChartJsColours.backgroundColors) - } - - for (let i = data.length; i >= 0; i--) { - if (data[i]?.length === 0) { - labels.splice(i, 1) - data.splice(i, 1) - borderColor.splice(i, 1) - backgroundColor.splice(i, 1) - } - } - - let barchartMode = tagRendering.multiAnswer - if (labels.length > 9) { - barchartMode = true - } - - const config = { - type: options.chartType ?? (barchartMode ? "bar" : "pie"), - data: { - labels, - datasets: [ - { - data: data.map((l) => l.length), - backgroundColor, - borderColor, - borderWidth: 1, - label: undefined, - }, - ], - }, - options: { - plugins: { - legend: { - display: !barchartMode, - }, - }, - }, - } - - const chart = new ChartJs(config).SetClass(options?.chartclasses ?? "w-32 h-32") - - if (options.chartstyle !== undefined) { - chart.SetStyle(options.chartstyle) - } - - super([chart]) - this.SetClass("flex flex-col justify-center h-full") - } public static extractDataAndLabels }>( tagRendering: TagRenderingConfig, @@ -375,7 +177,232 @@ export default class TagRenderingChart extends Combine { } data.push(...categoryCounts, ...otherData) labels.push(...(mappings?.map((m) => m.then.txt) ?? []), ...otherLabels) - + if(data.length === 0){ + return undefined + } return { labels, data } } + + /** + * Create a configuration for ChartJS. + * This will show a multi-coloured bar chart, where every bar will represent one day and + * every colour represents a certain value for the tagRendering. + * + * Mostly used in the StatisticsGUI + * + * @param tr + * @param features + * @param options + */ + static createPerDayConfigForTagRendering( + tr: TagRenderingConfig, + features: (OsmFeature & { properties: { date: string } })[], + options?: { + period: "day" | "month" + groupToOtherCutoff?: 3 | number + // If given, take the sum of these fields to get the feature weight + sumFields?: ReadonlyArray + hideUnknown?: boolean + hideNotApplicable?: boolean + } + ) { + const { labels, data } = ChartJsUtils.extractDataAndLabels(tr, features, { + sort: true, + groupToOtherCutoff: options?.groupToOtherCutoff, + hideNotApplicable: options?.hideNotApplicable, + hideUnkown: options?.hideUnknown, + }) + if (labels === undefined || data === undefined) { + console.error( + "Could not extract data and labels for ", + tr, + " with features", + features, + ": no labels or no data" + ) + throw "No labels or data given..." + } + + for (let i = labels.length; i >= 0; i--) { + if (data[i]?.length != 0) { + continue + } + data.splice(i, 1) + labels.splice(i, 1) + } + + const datasets: { + label: string /*themename*/ + data: number[] /*counts per day*/ + backgroundColor: string + }[] = [] + const allDays = ChartJsUtils.getAllDays(features) + let trimmedDays = allDays.map((d) => d.substring(0, 10)) + if (options?.period === "month") { + trimmedDays = trimmedDays.map((d) => d.substring(0, 7)) + } + trimmedDays = Utils.Dedup(trimmedDays) + + for (let i = 0; i < labels.length; i++) { + const label = labels[i] + const changesetsForTheme = data[i] + const perDay: Record = {} + for (const changeset of changesetsForTheme) { + const csDate = new Date(changeset.properties.date) + Utils.SetMidnight(csDate) + let str = csDate.toISOString() + str = str.substr(0, 10) + if (options?.period === "month") { + str = str.substr(0, 7) + } + if (perDay[str] === undefined) { + perDay[str] = [changeset] + } else { + perDay[str].push(changeset) + } + } + + const countsPerDay: number[] = [] + for (let i = 0; i < trimmedDays.length; i++) { + const day = trimmedDays[i] + + const featuresForDay = perDay[day] + if (!featuresForDay) { + continue + } + if (options.sumFields !== undefined) { + let sum = 0 + for (const featuresForDayElement of featuresForDay) { + const props = featuresForDayElement.properties + for (const key of options.sumFields) { + if (!props[key]) { + continue + } + const v = Number(props[key]) + if (isNaN(v)) { + continue + } + sum += v + } + } + countsPerDay[i] = sum + } else { + countsPerDay[i] = featuresForDay?.length ?? 0 + } + } + let backgroundColor = + ChartJsColours.borderColors[i % ChartJsColours.borderColors.length] + if (label === "Unknown") { + backgroundColor = ChartJsColours.unknownBorderColor + } + if (label === "Other") { + backgroundColor = ChartJsColours.otherBorderColor + } + datasets.push({ + data: countsPerDay, + backgroundColor, + label, + }) + } + + const perDayData = { + labels: trimmedDays, + datasets, + } + + return { + type: "bar", + data: perDayData, + options: { + responsive: true, + legend: { + display: false, + }, + scales: { + x: { + stacked: true, + }, + y: { + stacked: true, + }, + }, + }, + } + } + + /** + * Based on the tagRendering, creates a pie-chart or barchart (if multianswer) configuration for + * + * @returns undefined if not enough parameters + */ + static createConfigForTagRendering }>(tagRendering: TagRenderingConfig, features: T[], + options?: TagRenderingChartOptions){ + if (tagRendering.mappings?.length === 0 && tagRendering.freeform?.key === undefined) { + return undefined + } + + const { labels, data } = ChartJsUtils.extractDataAndLabels( + tagRendering, + features, + options + ) + if (labels === undefined || data === undefined) { + return undefined + } + + const borderColor = [ + ChartJsColours.unknownBorderColor, + ChartJsColours.otherBorderColor, + ChartJsColours.notApplicableBorderColor, + ] + const backgroundColor = [ + ChartJsColours.unknownColor, + ChartJsColours.otherColor, + ChartJsColours.notApplicableColor, + ] + + while (borderColor.length < data.length) { + borderColor.push(...ChartJsColours.borderColors) + backgroundColor.push(...ChartJsColours.backgroundColors) + } + + for (let i = data.length; i >= 0; i--) { + if (data[i]?.length === 0) { + labels.splice(i, 1) + data.splice(i, 1) + borderColor.splice(i, 1) + backgroundColor.splice(i, 1) + } + } + + let barchartMode = tagRendering.multiAnswer + if (labels.length > 9) { + barchartMode = true + } + + const config = { + type: options?.chartType ?? (barchartMode ? "bar" : "pie"), + data: { + labels, + datasets: [ + { + data: data.map((l) => l.length), + backgroundColor, + borderColor, + borderWidth: 1, + label: undefined, + }, + ], + }, + options: { + plugins: { + legend: { + display: !barchartMode, + }, + }, + }, + } + return config + + } } diff --git a/src/UI/History/AggregateView.svelte b/src/UI/History/AggregateView.svelte index b969758f9..f0f29c677 100644 --- a/src/UI/History/AggregateView.svelte +++ b/src/UI/History/AggregateView.svelte @@ -10,11 +10,11 @@ import Tr from "../Base/Tr.svelte" import AccordionSingle from "../Flowbite/AccordionSingle.svelte" import Translations from "../i18n/Translations" - import TagRenderingChart from "../BigComponents/TagRenderingChart" - import ToSvelte from "../Base/ToSvelte.svelte" import type { TagRenderingConfigJson } from "../../Models/ThemeConfig/Json/TagRenderingConfigJson" import { Or } from "../../Logic/Tags/Or" import { Utils } from "../../Utils" + import ChartJs from "../Base/ChartJs.svelte" + import { ChartJsUtils } from "../Base/ChartJsUtils" export let onlyShowUsername: string[] export let features: Feature[] @@ -138,13 +138,13 @@ {#if diff.tr}
- + } + )} />
{:else} Could not create a graph - this item type has no associated question diff --git a/src/UI/Statistics/LayerStatistics.svelte b/src/UI/Statistics/LayerStatistics.svelte index e3b27bfef..3ffac84b5 100644 --- a/src/UI/Statistics/LayerStatistics.svelte +++ b/src/UI/Statistics/LayerStatistics.svelte @@ -5,13 +5,14 @@ import LayerConfig from "../../Models/ThemeConfig/LayerConfig" import Tr from "../Base/Tr.svelte" import Loading from "../Base/Loading.svelte" - import ToSvelte from "../Base/ToSvelte.svelte" - import TagRenderingChart from "../BigComponents/TagRenderingChart" import type { Feature } from "geojson" import { AccordionItem } from "flowbite-svelte" import ThemeViewState from "../../Models/ThemeViewState" import DefaultIcon from "../Map/DefaultIcon.svelte" import { Store } from "../../Logic/UIEventSource" + import ChartJs from "../Base/ChartJs.svelte" + import { ChartJsUtils } from "../Base/ChartJsUtils" + import { Utils } from "../../Utils" export let layer: LayerConfig export let state: ThemeViewState @@ -21,6 +22,9 @@ ) let trs = layer.tagRenderings.filter((tr) => tr.question) + let configs = trs.map(tr => ({ tr, config: ChartJsUtils.createConfigForTagRendering(tr, $elements, { + hideNotApplicable: true + }) })).filter(ctr => ctr.config !== undefined) - {#each trs as tr} -

{#if tr.question}{:else} {tr.id}{/if}

- - new TagRenderingChart($elements, tr, { - chartclasses: "w-full self-center" - }).SetClass(tr.multiAnswer ? "w-128" : "w-96")} - /> + {#each configs as ctr} +
+

+ {#if ctr.tr.question} + + {:else} {ctr.tr.id}{/if} +

+ +
+ +
+ No data for this entry +
+
+
+
{/each} {/if} diff --git a/src/UI/Statistics/SingleStat.svelte b/src/UI/Statistics/SingleStat.svelte index 728b5744e..8069a4e9a 100644 --- a/src/UI/Statistics/SingleStat.svelte +++ b/src/UI/Statistics/SingleStat.svelte @@ -3,9 +3,9 @@ * 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" + import ChartJs from "../Base/ChartJs.svelte" + import { ChartJsUtils } from "../Base/ChartJsUtils" export let overview: ChangesetsOverview export let diffInDays: number @@ -22,29 +22,25 @@ {/if}

By number of changesets

-
- + 50 ? 25 : total > 10 ? 3 : 0, - chartstyle: "width: 24rem; height: 24rem", chartType: "doughnut", sort: true, - })} - /> + })}/>
- 50 ? 25 : total > 10 ? 3 : 0, - })} -/> + } +)} /> +

By number of modifications

- 50 ? 10 : 0, sumFields: ChangesetsOverview.valuesToSum, - })} -/> + })}/>