mapcomplete/UI/BigComponents/TagRenderingChart.ts

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

376 lines
12 KiB
TypeScript
Raw Normal View History

2022-07-20 12:04:14 +02:00
import ChartJs from "../Base/ChartJs"
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
import { ChartConfiguration } from "chart.js"
import Combine from "../Base/Combine"
2022-07-20 14:06:39 +02:00
import { TagUtils } from "../../Logic/Tags/TagUtils"
2022-08-20 12:46:33 +02:00
import { Utils } from "../../Utils"
import { OsmFeature } from "../../Models/OsmFeature"
2022-07-20 12:04:14 +02:00
2022-08-18 23:37:44 +02:00
export interface TagRenderingChartOptions {
groupToOtherCutoff?: 3 | number
sort?: boolean
}
2022-08-20 12:46:33 +02:00
export class StackedRenderingChart extends ChartJs {
2022-08-22 13:34:47 +02:00
constructor(
tr: TagRenderingConfig,
features: (OsmFeature & { properties: { date: string } })[],
options?: {
period: "day" | "month"
groupToOtherCutoff?: 3 | number
}
) {
2022-08-20 12:46:33 +02:00
const { labels, data } = TagRenderingChart.extractDataAndLabels(tr, features, {
2022-08-22 13:34:47 +02:00
sort: true,
groupToOtherCutoff: options?.groupToOtherCutoff,
2022-08-20 12:46:33 +02:00
})
if (labels === undefined || data === undefined) {
2022-09-02 21:40:13 +02:00
console.error("Could not extract data and labels for ", tr, " with features", features)
2022-08-20 12:46:33 +02:00
throw "No labels or data given..."
}
// labels: ["cyclofix", "buurtnatuur", ...]; data : [ ["cyclofix-changeset", "cyclofix-changeset", ...], ["buurtnatuur-cs", "buurtnatuur-cs"], ... ]
2022-08-22 13:34:47 +02:00
for (let i = labels.length; i >= 0; i--) {
if (data[i]?.length != 0) {
continue
}
data.splice(i, 1)
labels.splice(i, 1)
}
2022-08-20 12:46:33 +02:00
const datasets: {
label: string /*themename*/
data: number[] /*counts per day*/
backgroundColor: string
}[] = []
const allDays = StackedRenderingChart.getAllDays(features)
2022-09-02 21:40:13 +02:00
let trimmedDays = allDays.map((d) => d.substr(0, 10))
2022-08-22 13:34:47 +02:00
if (options?.period === "month") {
2022-08-20 12:46:33 +02:00
trimmedDays = trimmedDays.map((d) => d.substr(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<string, OsmFeature[]> = {}
for (const changeset of changesetsForTheme) {
const csDate = new Date(changeset.properties.date)
Utils.SetMidnight(csDate)
let str = csDate.toISOString()
2022-09-02 21:40:13 +02:00
str = str.substr(0, 10)
2022-08-22 13:34:47 +02:00
if (options?.period === "month") {
2022-09-02 21:40:13 +02:00
str = str.substr(0, 7)
2022-08-20 12:46:33 +02:00
}
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]
countsPerDay[i] = perDay[day]?.length ?? 0
}
2022-08-22 13:34:47 +02:00
let backgroundColor =
TagRenderingChart.borderColors[i % TagRenderingChart.borderColors.length]
if (label === "Unknown") {
backgroundColor = TagRenderingChart.unkownBorderColor
}
if (label === "Other") {
backgroundColor = TagRenderingChart.otherBorderColor
}
2022-08-20 12:46:33 +02:00
datasets.push({
data: countsPerDay,
2022-08-22 13:34:47 +02:00
backgroundColor,
2022-08-20 12:46:33 +02:00
label,
})
}
const perDayData = {
labels: trimmedDays,
datasets,
}
const config = <ChartConfiguration>{
type: "bar",
data: perDayData,
options: {
responsive: true,
legend: {
display: false,
},
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
},
},
},
}
super(config)
}
2022-08-22 13:34:47 +02:00
public static getAllDays(
features: (OsmFeature & { properties: { date: string } })[]
): string[] {
2022-08-20 12:46:33 +02:00
let earliest: Date = undefined
let latest: Date = undefined
let allDates = new Set<string>()
features.forEach((value, key) => {
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())
})
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
export default class TagRenderingChart extends Combine {
2022-08-22 13:34:47 +02:00
public static readonly unkownColor = "rgba(128, 128, 128, 0.2)"
public static readonly unkownBorderColor = "rgba(128, 128, 128, 0.2)"
2022-07-20 12:04:14 +02:00
2022-08-22 13:34:47 +02:00
public static readonly otherColor = "rgba(128, 128, 128, 0.2)"
public static readonly otherBorderColor = "rgba(128, 128, 255)"
public static readonly notApplicableColor = "rgba(128, 128, 128, 0.2)"
public static readonly notApplicableBorderColor = "rgba(255, 0, 0)"
2022-07-20 12:04:14 +02:00
2022-08-18 23:37:44 +02:00
public static readonly backgroundColors = [
2022-07-20 12:04:14 +02:00
"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)",
]
2022-08-20 12:46:33 +02:00
public static readonly borderColors = [
2022-07-20 12:04:14 +02:00
"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
*/
2022-08-22 13:34:47 +02:00
constructor(
features: { properties: Record<string, string> }[],
tagRendering: TagRenderingConfig,
options?: TagRenderingChartOptions & {
chartclasses?: string
2022-07-20 15:04:51 +02:00
chartstyle?: string
includeTitle?: boolean
2022-08-22 13:34:47 +02:00
chartType?: "pie" | "bar" | "doughnut"
}
) {
2022-08-18 23:37:44 +02:00
if (tagRendering.mappings?.length === 0 && tagRendering.freeform?.key === undefined) {
2022-07-20 14:39:19 +02:00
super([])
this.SetClass("hidden")
2022-07-20 12:04:14 +02:00
return
}
2022-08-18 23:37:44 +02:00
const { labels, data } = TagRenderingChart.extractDataAndLabels(
tagRendering,
features,
options
)
if (labels === undefined || data === undefined) {
2022-07-20 14:39:19 +02:00
super([])
this.SetClass("hidden")
2022-08-22 13:34:47 +02:00
return
2022-07-20 14:06:39 +02:00
}
2022-08-22 13:34:47 +02:00
2022-07-20 12:04:14 +02:00
const borderColor = [
TagRenderingChart.unkownBorderColor,
TagRenderingChart.otherBorderColor,
TagRenderingChart.notApplicableBorderColor,
]
const backgroundColor = [
TagRenderingChart.unkownColor,
TagRenderingChart.otherColor,
TagRenderingChart.notApplicableColor,
]
2022-07-20 14:06:39 +02:00
2022-07-20 12:04:14 +02:00
while (borderColor.length < data.length) {
borderColor.push(...TagRenderingChart.borderColors)
backgroundColor.push(...TagRenderingChart.backgroundColors)
}
for (let i = data.length; i >= 0; i--) {
2022-08-18 23:37:44 +02:00
if (data[i]?.length === 0) {
2022-07-20 12:04:14 +02:00
labels.splice(i, 1)
data.splice(i, 1)
borderColor.splice(i, 1)
backgroundColor.splice(i, 1)
}
}
2022-08-22 13:34:47 +02:00
2022-08-18 23:37:44 +02:00
let barchartMode = tagRendering.multiAnswer
if (labels.length > 9) {
2022-07-20 15:04:51 +02:00
barchartMode = true
2022-07-20 12:04:14 +02:00
}
2022-07-20 15:04:51 +02:00
2022-07-20 12:04:14 +02:00
const config = <ChartConfiguration>{
2022-08-18 23:37:44 +02:00
type: options.chartType ?? (barchartMode ? "bar" : "doughnut"),
2022-07-20 12:04:14 +02:00
data: {
labels,
datasets: [
{
2022-08-18 23:37:44 +02:00
data: data.map((l) => l.length),
2022-07-20 12:04:14 +02:00
backgroundColor,
borderColor,
borderWidth: 1,
label: undefined,
},
2022-09-08 21:40:48 +02:00
],
2022-07-20 12:04:14 +02:00
},
options: {
plugins: {
legend: {
2022-07-20 14:06:39 +02:00
display: !barchartMode,
2022-07-20 12:04:14 +02:00
},
},
},
}
const chart = new ChartJs(config).SetClass(options?.chartclasses ?? "w-32 h-32")
if (options.chartstyle !== undefined) {
chart.SetStyle(options.chartstyle)
}
2022-07-20 14:06:39 +02:00
2022-07-20 12:04:14 +02:00
super([
2022-08-18 23:37:44 +02:00
options?.includeTitle ? tagRendering.question.Clone() ?? tagRendering.id : undefined,
2022-07-20 12:04:14 +02:00
chart,
])
this.SetClass("block")
}
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 ?? {}
2022-08-22 13:34:47 +02:00
let unknownCount: T[] = []
const categoryCounts: T[][] = mappings.map((_) => [])
2022-08-18 23:37:44 +02:00
const otherCounts: Record<string, T[]> = {}
2022-08-22 13:34:47 +02:00
let 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
2022-08-22 13:34:47 +02:00
let 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)
}
}
const labels = [
"Unknown",
"Other",
"Not applicable",
...(mappings?.map((m) => m.then.txt) ?? []),
...otherLabels,
2022-08-22 13:34:47 +02:00
]
const data: T[][] = [
unknownCount,
otherGrouped,
notApplicable,
...categoryCounts,
...otherData,
2022-09-08 21:40:48 +02:00
]
2022-08-18 23:37:44 +02:00
return { labels, data }
}
2022-07-20 12:04:14 +02:00
}