const { STORE_KEYS } = require("../model/stores"); const { settings } = require("../model"); const { today, log, deltaTime, isMobile } = require("../js/misc"); const { View } = require("./view"); require("./custom-checkbox"); const moment = require("moment"); const { Chart, registerables } = require("chart.js"); require("chartjs-adapter-moment"); Chart.register(...registerables); class ItemsChart extends View { constructor() { super(); this.unitPrice = false; this.innerHTML = /*html*/ `
-
`; this.elements.startDate.value = settings.startDate; this.setupEventHandlers(); this.addEventListener("x-change", () => { this.render(); }); } uniqueDates(items, startDate, endDate) { const allDates = items.flatMap((product) => product.priceHistory.filter((price) => price.date >= startDate && price.date <= endDate).map((item) => item.date) ); let uniqueDates = new Set(allDates); uniqueDates.add(startDate); uniqueDates.add(endDate); uniqueDates = [...uniqueDates]; uniqueDates.sort(); return uniqueDates; } calculateItemPriceTimeSeries(product, percentageChange, startDate, uniqueDates) { const getPrice = this.unitPrice ? (o) => o.unitPrice : (o) => o.price; const priceScratch = new Array(uniqueDates.length); let startPrice = null; const priceHistoryLookup = {}; priceScratch.fill(null); if (!product.priceHistoryLookup) { product.priceHistory.forEach((price) => { priceHistoryLookup[price.date] = price; if (!startPrice && price.date <= startDate) { startPrice = getPrice(price); } }); } if (startPrice == null) { const firstPrice = product.priceHistory[product.priceHistory.length - 1]; startPrice = getPrice(firstPrice); } for (let i = 0; i < uniqueDates.length; i++) { const priceObj = priceHistoryLookup[uniqueDates[i]]; priceScratch[i] = priceObj ? getPrice(priceObj) : null; } for (let i = 0; i < priceScratch.length; i++) { if (priceScratch[i] == null) { priceScratch[i] = startPrice; } else { startPrice = priceScratch[i]; } } if (priceScratch.some((price) => price == null)) { return null; } if (percentageChange) { const firstPrice = priceScratch.find((price) => price != 0); if (firstPrice == 0) return null; for (let i = 0; i < priceScratch.length; i++) { priceScratch[i] = ((priceScratch[i] - firstPrice) / firstPrice) * 100; } } if (priceScratch.some((price) => isNaN(price))) { return null; } return priceScratch; } calculateOverallPriceChanges(items, onlyToday, percentageChange, startDate, endDate) { if (items.length == 0) return { dates: [], changes: [] }; const getPrice = this.unitPrice ? (o) => o.unitPrice : (o) => o.price; if (onlyToday) { let sum = 0; for (const item of items) sum += getPrice(item); return [{ date: today(), price: sum }]; } const dates = this.uniqueDates(items, startDate, endDate); let priceChanges = new Array(dates.length); for (let i = 0; i < dates.length; i++) { priceChanges[i] = { date: dates[i], price: 0, unitPrice: 0 }; } let numItems = 0; items.forEach((product) => { const priceScratch = this.calculateItemPriceTimeSeries(product, percentageChange, startDate, dates); if (priceScratch == null) return; numItems++; for (let i = 0; i < priceScratch.length; i++) { const price = priceScratch[i]; priceChanges[i].price += price; priceChanges[i].unitPrice += price; } }); if (percentageChange) { for (let i = 0; i < priceChanges.length; i++) { priceChanges[i].price /= numItems; priceChanges[i].unitPrice /= numItems; } for (let i = 0; i < priceChanges.length; i++) { priceChanges[i].price = priceChanges[i].price.toFixed(2); priceChanges[i].unitPrice = priceChanges[i].unitPrice.toFixed(2); } } return priceChanges; } renderChart(items, chartType, startDate) { const getPrice = this.unitPrice ? (o) => o.unitPrice : (o) => o.price; const canvasDom = this.elements.canvas; const noData = this.elements.noData; if (items.length === 0) { canvasDom.classList.add("hidden"); noData.classList.remove("hidden"); return; } else { canvasDom.classList.remove("hidden"); noData.classList.add("hidden"); } const percentageChange = this.elements.percentageChange.checked; const now = performance.now(); const datasets = items.map((item) => { const prices = item.priceHistory; const dataset = { label: (item.store ? item.store + " " : "") + item.name, data: prices.map((price) => { return { x: moment(price.date), y: getPrice(price), }; }), }; if (settings.chartType == "stepped") { dataset.stepped = prices.length % 2 == 0 ? "after" : "before"; } return dataset; }); log("ItemsChart - Calculating datasets took " + ((performance.now() - now) / 1000).toFixed(2) + " secs"); const ctx = canvasDom.getContext("2d"); let scrollTop = -1; if (canvasDom.lastChart) { scrollTop = document.documentElement.scrollTop; canvasDom.lastChart.destroy(); } let yAxis = { ticks: { callback: function (value, index, ticks) { return value.toLocaleString("de-DE", { minimumFractionDigits: 2, style: "currency", currency: "EUR", }); }, }, }; if (percentageChange) { yAxis = { title: { display: true, text: "Änderung in % seit " + startDate, }, ticks: { callback: (value) => { return value + "%"; }, }, }; } canvasDom.lastChart = new Chart(ctx, { type: chartType ? chartType : "line", data: { datasets: datasets, }, options: { layout: { padding: 16, }, animation: false, responsive: true, maintainAspectRatio: false, scales: { x: { type: "time", adapters: { date: moment, }, time: { unit: "day", displayFormats: { day: "YYYY-MM-D", }, }, title: { display: true, text: "Date", }, }, y: yAxis, }, }, }); if (scrollTop != -1) document.documentElement.scrollTop = scrollTop; } render() { if (!this.model) return; const start = performance.now(); const items = this.model.filteredItems; const elements = this.elements; const onlyToday = this.elements.onlyToday.checked; let startDate = this.elements.startDate.value; let endDate = this.elements.endDate.value; let validDate = /^20\d{2}-\d{2}-\d{2}$/; if (!validDate.test(startDate)) startDate = "2017-01-01"; if (!validDate.test(endDate)) endDate = today(); const percentageChange = this.elements.percentageChange.checked; const itemsToShow = []; if (elements.sumTotal.checked && items.length > 0) { const now = performance.now(); itemsToShow.push({ name: "Preissumme Gesamt", priceHistory: this.calculateOverallPriceChanges(items, onlyToday, percentageChange, startDate, endDate), }); log("ItemsChart - Calculating overall sum total " + ((performance.now() - now) / 1000).toFixed(2) + " secs"); } if (elements.sumStores.checked && items.length > 0) { const now = performance.now(); STORE_KEYS.forEach((store) => { const storeItems = items.filter((item) => item.store === store); if (storeItems.length > 0) { itemsToShow.push({ name: "Preissumme " + store, priceHistory: this.calculateOverallPriceChanges(storeItems, onlyToday, percentageChange, startDate, endDate), }); } }); log("ItemsChart - Calculating overall sum per store took " + ((performance.now() - now) / 1000).toFixed(2) + " secs"); } items.forEach((item) => { if (item.chart) { const dates = this.uniqueDates([item], startDate, endDate); const prices = this.calculateItemPriceTimeSeries(item, percentageChange, startDate, dates).map((price) => percentageChange ? price.toFixed(2) : price ); const priceHistory = []; for (let i = 0; i < dates.length; i++) { priceHistory.push({ date: dates[i], price: prices[i] }); } const chartItem = { name: item.store + " " + item.name, priceHistory, }; itemsToShow.push(chartItem); } }); this.renderChart(itemsToShow, onlyToday ? "bar" : "line", startDate); log(`ItemsChart - charted ${itemsToShow.length} items in ${deltaTime(start).toFixed(2)} secs`); } } customElements.define("items-chart", ItemsChart);