MapComplete/src/UI/Base/ChartJsUtils.ts

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

447 lines
15 KiB
TypeScript
Raw Normal View History

2022-07-20 12:04:14 +02:00
import { ChartConfiguration } from "chart.js"
2025-07-12 14:40:08 +02:00
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
2022-07-20 14:06:39 +02:00
import { TagUtils } from "../../Logic/Tags/TagUtils"
2022-08-20 12:46:33 +02:00
import { OsmFeature } from "../../Models/OsmFeature"
2025-07-12 14:40:08 +02:00
import { Utils } from "../../Utils"
import { Lists } from "../../Utils/Lists"
2022-07-20 12:04:14 +02:00
class ChartJsColours {
2025-07-12 14:40:08 +02:00
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)",
]
}
2022-08-18 23:37:44 +02:00
export interface TagRenderingChartOptions {
groupToOtherCutoff?: 3 | number
2024-04-13 02:40:21 +02:00
sort?: boolean
hideUnkown?: boolean
2025-08-13 23:06:38 +02:00
hideNotApplicable?: boolean
2025-07-12 14:40:08 +02:00
chartType?: "pie" | "bar" | "doughnut"
2022-08-18 23:37:44 +02:00
}
2025-07-12 14:40:08 +02:00
export class ChartJsUtils {
/**
* 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(
2022-08-22 13:34:47 +02:00
features: (OsmFeature & { properties: { date: string } })[]
): string[] {
2022-08-20 12:46:33 +02:00
let earliest: Date = undefined
let latest: Date = undefined
const allDates = new Set<string>()
2025-07-12 14:40:08 +02:00
for (const value of features) {
2022-08-20 12:46:33 +02:00
const d = new Date(value.properties.date)
Utils.SetMidnight(d)
2022-08-22 13:34:47 +02:00
2022-08-20 12:46:33 +02:00
if (earliest === undefined) {
earliest = d
} else if (d < earliest) {
earliest = d
}
if (latest === undefined) {
latest = d
} else if (d > latest) {
latest = d
}
allDates.add(d.toISOString())
2025-07-12 14:40:08 +02:00
}
2022-08-20 12:46:33 +02:00
while (earliest < latest) {
earliest.setDate(earliest.getDate() + 1)
allDates.add(earliest.toISOString())
}
const days = Array.from(allDates)
days.sort()
return days
}
2022-07-20 12:04:14 +02:00
2022-08-22 13:34:47 +02:00
public static extractDataAndLabels<T extends { properties: Record<string, string> }>(
tagRendering: TagRenderingConfig,
features: T[],
options?: TagRenderingChartOptions
): { labels: string[]; data: T[][] } {
2022-08-18 23:37:44 +02:00
const mappings = tagRendering.mappings ?? []
options = options ?? {}
const unknownCount: T[] = []
const categoryCounts: T[][] = mappings.map(() => [])
2022-08-18 23:37:44 +02:00
const otherCounts: Record<string, T[]> = {}
const notApplicable: T[] = []
2022-08-18 23:37:44 +02:00
for (const feature of features) {
const props = feature.properties
if (
tagRendering.condition !== undefined &&
!tagRendering.condition.matchesProperties(props)
) {
notApplicable.push(feature)
continue
}
if (!tagRendering.IsKnown(props)) {
unknownCount.push(feature)
continue
}
let foundMatchingMapping = false
if (!tagRendering.multiAnswer) {
for (let i = 0; i < mappings.length; i++) {
const mapping = mappings[i]
if (mapping.if.matchesProperties(props)) {
categoryCounts[i].push(feature)
foundMatchingMapping = true
break
}
}
} else {
for (let i = 0; i < mappings.length; i++) {
const mapping = mappings[i]
if (TagUtils.MatchesMultiAnswer(mapping.if, props)) {
categoryCounts[i].push(feature)
foundMatchingMapping = true
}
}
}
if (!foundMatchingMapping) {
if (
tagRendering.freeform?.key !== undefined &&
props[tagRendering.freeform.key] !== undefined
) {
const otherValue = props[tagRendering.freeform.key]
otherCounts[otherValue] = otherCounts[otherValue] ?? []
2022-08-22 13:34:47 +02:00
otherCounts[otherValue].push(feature)
2022-08-18 23:37:44 +02:00
} else {
unknownCount.push(feature)
}
}
}
if (unknownCount.length + notApplicable.length === features.length) {
console.log("Returning no label nor data: all features are unkown or notApplicable")
return { labels: undefined, data: undefined }
}
2022-07-20 12:04:14 +02:00
const otherGrouped: T[] = []
2022-08-18 23:37:44 +02:00
const otherLabels: string[] = []
const otherData: T[][] = []
const sortedOtherCounts: [string, T[]][] = []
for (const v in otherCounts) {
sortedOtherCounts.push([v, otherCounts[v]])
}
if (options?.sort) {
sortedOtherCounts.sort((a, b) => b[1].length - a[1].length)
}
for (const [v, count] of sortedOtherCounts) {
if (count.length >= (options.groupToOtherCutoff ?? 3)) {
otherLabels.push(v)
otherData.push(otherCounts[v])
} else {
otherGrouped.push(...count)
}
}
2024-03-28 03:39:46 +01:00
const labels = []
const data: T[][] = []
if (!options.hideUnkown) {
data.push(unknownCount)
labels.push("Unknown")
}
data.push(otherGrouped)
labels.push("Other")
if (!options.hideNotApplicable) {
data.push(notApplicable)
2024-04-13 02:40:21 +02:00
labels.push("Not applicable")
2024-03-28 03:39:46 +01:00
}
2024-04-13 02:40:21 +02:00
data.push(...categoryCounts, ...otherData)
labels.push(...(mappings?.map((m) => m.then.txt) ?? []), ...otherLabels)
2025-08-13 23:06:38 +02:00
if (data.length === 0) {
2025-07-12 14:40:08 +02:00
return undefined
}
2022-08-18 23:37:44 +02:00
return { labels, data }
}
2025-07-12 14:40:08 +02:00
/**
* 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(
2025-08-13 23:06:38 +02:00
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<string>
hideUnknown?: boolean
hideNotApplicable?: boolean
}
): ChartConfiguration {
2025-08-13 23:06:38 +02:00
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
2025-07-12 14:40:08 +02:00
}
2025-08-13 23:06:38 +02:00
data.splice(i, 1)
labels.splice(i, 1)
}
2025-07-12 14:40:08 +02:00
2025-08-13 23:06:38 +02:00
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 = Lists.dedup(trimmedDays)
for (let i = 0; i < labels.length; i++) {
const label = labels[i]
const changesetsForTheme = data[i]
const perDay: Record<string, OsmFeature[]> = {}
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)
2025-07-12 14:40:08 +02:00
}
}
2025-08-13 23:06:38 +02:00
const countsPerDay: number[] = []
for (let i = 0; i < trimmedDays.length; i++) {
const day = trimmedDays[i]
2025-07-12 14:40:08 +02:00
2025-08-13 23:06:38 +02:00
const featuresForDay = perDay[day]
if (!featuresForDay) {
continue
2025-07-12 14:40:08 +02:00
}
2025-08-13 23:06:38 +02:00
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
2025-07-12 14:40:08 +02:00
}
2025-08-13 23:06:38 +02:00
sum += v
2025-07-12 14:40:08 +02:00
}
}
2025-08-13 23:06:38 +02:00
countsPerDay[i] = sum
} else {
countsPerDay[i] = featuresForDay?.length ?? 0
2025-07-12 14:40:08 +02:00
}
}
2025-08-13 23:06:38 +02:00
let backgroundColor =
ChartJsColours.borderColors[i % ChartJsColours.borderColors.length]
if (label === "Unknown") {
backgroundColor = ChartJsColours.unknownBorderColor
}
if (label === "Other") {
backgroundColor = ChartJsColours.otherBorderColor
2025-07-12 14:40:08 +02:00
}
2025-08-13 23:06:38 +02:00
datasets.push({
data: countsPerDay,
backgroundColor,
label,
})
}
2025-07-12 14:40:08 +02:00
2025-08-13 23:06:38 +02:00
const perDayData = {
labels: trimmedDays,
datasets,
}
return <ChartConfiguration>{
type: "bar",
data: perDayData,
options: {
responsive: true,
legend: {
display: false,
},
scales: {
x: {
stacked: true,
2025-07-12 14:40:08 +02:00
},
2025-08-13 23:06:38 +02:00
y: {
stacked: true,
2025-07-12 14:40:08 +02:00
},
},
2025-08-13 23:06:38 +02:00
},
}
2025-07-12 14:40:08 +02:00
}
/**
* Based on the tagRendering, creates a pie-chart or barchart (if multianswer) configuration for
*
* @returns undefined if not enough parameters
*/
2025-08-13 23:06:38 +02:00
static createConfigForTagRendering<T extends { properties: Record<string, string> }>(
tagRendering: TagRenderingConfig,
features: T[],
options?: TagRenderingChartOptions
): ChartConfiguration {
2025-07-12 14:40:08 +02:00
if (tagRendering.mappings?.length === 0 && tagRendering.freeform?.key === undefined) {
return undefined
}
2025-08-13 23:06:38 +02:00
const { labels, data } = ChartJsUtils.extractDataAndLabels(tagRendering, features, options)
2025-07-12 14:40:08 +02:00
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
}
return <ChartConfiguration>{
2025-07-12 14:40:08 +02:00
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,
},
},
},
}
}
2025-08-13 23:06:38 +02:00
static createHistogramConfig(keys: string[], counts: Map<string, number>) {
const borderColor = []
const backgroundColor = []
while (borderColor.length < keys.length) {
borderColor.push(...ChartJsColours.borderColors)
backgroundColor.push(...ChartJsColours.backgroundColors)
}
2025-08-13 23:06:38 +02:00
return <ChartConfiguration>{
type: "bar",
data: {
labels: keys,
datasets: [
{
data: keys.map((k) => counts.get(k)),
backgroundColor,
borderColor,
borderWidth: 1,
label: undefined,
},
],
},
2025-08-13 23:06:38 +02:00
options: {
scales: {
y: {
ticks: {
stepSize: 1,
2025-08-13 23:06:38 +02:00
callback: (value) => Number(value).toFixed(0),
},
},
},
plugins: {
legend: {
display: false,
},
},
},
}
}
2022-07-20 12:04:14 +02:00
}