From 416db5b5b43670439bf6b36d0c8f767379fdf885 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 11 Jun 2023 18:29:31 +0200 Subject: [PATCH] Charts, various fixes. --- package-lock.json | 17 ++++ package.json | 1 + site/changes-new.html | 2 +- site/index-new.html | 2 +- site/tailwind.css | 6 +- site/views/items-chart.js | 185 +++++++++++++++++++++++++++++++++++++ site/views/items-filter.js | 29 ++---- site/views/items-list.js | 127 +++++++++++++++++++------ site/views/view.js | 20 +++- 9 files changed, 336 insertions(+), 53 deletions(-) create mode 100644 site/views/items-chart.js diff --git a/package-lock.json b/package-lock.json index 54cd487..e644cca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "alasql": "^4.0.4", "axios": "^1.4.0", + "chart.js": "^4.3.0", "chokidar": "^3.5.3", "compression": "^1.7.4", "express": "^4.18.2", @@ -460,6 +461,11 @@ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", "dev": true }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -917,6 +923,17 @@ "node": ">=8" } }, + "node_modules/chart.js": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.3.0.tgz", + "integrity": "sha512-ynG0E79xGfMaV2xAHdbhwiPLczxnNNnasrmPEXriXsPJGjmhOBYzFVEsB65w2qMDz+CaBJJuJD0inE/ab/h36g==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=7" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", diff --git a/package.json b/package.json index 2fdb9cd..13fa6e3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "alasql": "^4.0.4", "axios": "^1.4.0", + "chart.js": "^4.3.0", "chokidar": "^3.5.3", "compression": "^1.7.4", "express": "^4.18.2", diff --git a/site/changes-new.html b/site/changes-new.html index 5f11d2a..678933b 100644 --- a/site/changes-new.html +++ b/site/changes-new.html @@ -3,7 +3,7 @@

Preisänderungen

- +
diff --git a/site/index-new.html b/site/index-new.html index 0703126..8bf7ea4 100644 --- a/site/index-new.html +++ b/site/index-new.html @@ -3,7 +3,7 @@

Produktsuche

- +
diff --git a/site/tailwind.css b/site/tailwind.css index 65fc557..24312f8 100644 --- a/site/tailwind.css +++ b/site/tailwind.css @@ -2,7 +2,7 @@ @tailwind components; @tailwind utilities; -canvas.hidden { +*.hidden { display: none !important; } @@ -184,3 +184,7 @@ thead > tr { @apply md:mt-6; @apply block; } + +.hidden { + display: none !important; +} diff --git a/site/views/items-chart.js b/site/views/items-chart.js new file mode 100644 index 0000000..ad5de5a --- /dev/null +++ b/site/views/items-chart.js @@ -0,0 +1,185 @@ +const { STORE_KEYS } = require("../model/stores"); +const { today } = require("../misc"); +const { View } = require("./view"); +require("./custom-checkbox"); +const { Chart, registerables } = require("chart.js"); +Chart.register(...registerables); + +class ItemsChart extends View { + constructor() { + super(); + + this.innerHTML = /*html*/ ` +
+ +
+ + + +
+ + - + +
+
+
+ `; + this.setupEventHandlers(); + this.addEventListener("change", () => { + this.render(); + }); + } + + calculateOverallPriceChanges(items, onlyToday, startDate, endDate) { + if (items.length == 0) return { dates: [], changes: [] }; + + if (onlyToday) { + let sum = 0; + for (const item of items) sum += item.price; + return [{ date: today(), price: sum }]; + } + + const allDates = items.flatMap((product) => product.priceHistory.map((item) => item.date)); + let uniqueDates = [...new Set(allDates)]; + uniqueDates.sort(); + + const allPrices = items.map((product) => { + let price = null; + const prices = uniqueDates.map((date) => { + const priceObj = product.priceHistory.find((item) => item.date === date); + if (!price && priceObj) price = priceObj.price; + return priceObj ? priceObj.price : null; + }); + + for (let i = 0; i < prices.length; i++) { + if (!prices[i]) { + prices[i] = price; + } else { + price = prices[i]; + } + } + return prices; + }); + + const priceChanges = []; + for (let i = 0; i < uniqueDates.length; i++) { + if (uniqueDates[i] < startDate || uniqueDates[i] > endDate) continue; + let price = 0; + for (let j = 0; j < allPrices.length; j++) { + price += allPrices[j][i]; + } + priceChanges.push({ date: uniqueDates[i], price }); + } + + return priceChanges; + } + + renderChart(items, chartType) { + const canvasDom = this.elements.canvas; + if (items.length === 0) { + canvasDom.classList.add("hidden"); + return; + } else { + canvasDom.classList.remove("hidden"); + } + + const allDates = items.flatMap((product) => product.priceHistory.map((item) => item.date)); + const uniqueDates = [...new Set(allDates)]; + uniqueDates.sort(); + + const datasets = items.map((product) => { + let price = null; + const prices = uniqueDates.map((date) => { + const priceObj = product.priceHistory.find((item) => item.date === date); + if (!price && priceObj) price = priceObj.price; + return priceObj ? priceObj.price : null; + }); + + for (let i = 0; i < prices.length; i++) { + if (prices[i] == null) { + prices[i] = price; + } else { + price = prices[i]; + } + } + + return { + label: (product.store ? product.store + " " : "") + product.name, + data: prices, + }; + }); + + const ctx = canvasDom.getContext("2d"); + let scrollTop = -1; + if (canvasDom.lastChart) { + scrollTop = document.documentElement.scrollTop; + canvasDom.lastChart.destroy(); + } + canvasDom.lastChart = new Chart(ctx, { + type: chartType ? chartType : "line", + data: { + labels: uniqueDates, + datasets: datasets, + }, + options: { + responsive: true, + aspectRation: 16 / 9, + scales: { + y: { + title: { + display: true, + text: "EURO", + }, + }, + }, + }, + }); + if (scrollTop != -1) document.documentElement.scrollTop = scrollTop; + } + + render() { + if (!this.model) return; + const items = this.model.filteredItems; + const elements = this.elements; + const onlyToday = this.elements.onlyToday.checked; + const startDate = this.elements.startDate.value; + const endDate = this.elements.endDate.value; + const itemsToShow = []; + + if (elements.sumTotal.checked && items.length > 0) { + itemsToShow.push({ + name: "Preissumme Gesamt", + priceHistory: this.calculateOverallPriceChanges(items, onlyToday, startDate, endDate), + }); + } + + if (elements.sumStores.checked && items.length > 0) { + 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, startDate, endDate), + }); + } + }); + } + + items.forEach((item) => { + if (item.chart) { + itemsToShow.push({ + name: item.store + " " + item.name, + priceHistory: onlyToday + ? [{ date: today(), price: item.price }] + : item.priceHistory.filter((price) => price.date >= startDate && price.date <= endDate), + }); + } + }); + + console.log("Items to show " + itemsToShow.length); + + this.renderChart(itemsToShow, onlyToday ? "bar" : "line"); + } +} +customElements.define("items-chart", ItemsChart); diff --git a/site/views/items-filter.js b/site/views/items-filter.js index 237bed6..28d62fb 100644 --- a/site/views/items-filter.js +++ b/site/views/items-filter.js @@ -19,7 +19,7 @@ class ItemsFilter extends View { const placeholder = this.hasAttribute("placeholder") ? this.getAttribute("placeholder") : "Produkte suchen..."; this.innerHTML = /*html*/ ` - +
@@ -104,29 +104,9 @@ class ItemsFilter extends View { this.setupEventHandlers(); - this.addEventListener("change", () => { + this.addEventListener("change", (event) => { this.filter(); }); - - let stateParents = [this]; - View.traverse( - this, - [], - (parents, element) => { - if (element.hasAttribute("x-state")) { - console.log( - (stateParents.length > 0 ? stateParents.map((p) => p.getAttribute("x-id")).join(" -> ") + " -> " : "") + - element.getAttribute("x-id") - ); - stateParents.push(element); - } - return true; - }, - (parents, element) => { - if (element.hasAttribute("x-state")) stateParents.pop(); - } - ); - console.log(this.state); } filter() { @@ -216,6 +196,11 @@ class ItemsFilter extends View { filteredItems = queryItems(query, filteredItems); } + if (this._lastQuery != query) { + filteredItems.forEach((item) => (item.chart = false)); + } + this._lastQuery = query; + console.log("Filtering items took " + (performance.now() - now) / 1000 + " secs"); this.model.removeListener(this._listener); diff --git a/site/views/items-list.js b/site/views/items-list.js index 4ca78d1..0895415 100644 --- a/site/views/items-list.js +++ b/site/views/items-list.js @@ -1,22 +1,30 @@ -const { downloadJSON, dom, onVisibleOnce, isMobile } = require("../misc"); +const { downloadJSON, dom, onVisibleOnce, isMobile, getBooleanAttribute } = require("../misc"); const { vectorizeItems, similaritySortItems } = require("../knn"); const { stores } = require("../model/stores"); const { View } = require("./view"); +const { ItemsChart } = require("./items-chart"); class ItemsList extends View { constructor() { super(); + this._share = getBooleanAttribute(this, "share"); + this._json = getBooleanAttribute(this, "json"); + this._chart = getBooleanAttribute(this, "chart"); + this._remove = getBooleanAttribute(this, "remove"); + this._remove = getBooleanAttribute(this, "add"); + this._updown = getBooleanAttribute(this, "updown"); + this.innerHTML = /*html*/ `
- Teilen - JSON + + - +
+ @@ -45,33 +54,25 @@ class ItemsList extends View {
`; - this._itemTemplate = dom( - "tr", - /*html*/ ` - - -
- - -
- - - - - - - - - - ` - ); - const elements = this.elements; + if (!this._share) elements.shareLink.classList.remove("hidden"); + if (!this._json) elements.json.classList.remove("hidden"); + if (!this._chart) elements.chart.classList.remove("hidden"); + elements.json.addEventListener("click", (event) => { event.preventDefault(); if (!this.model) return; - downloadJSON("items.json", this.model.filteredItems); + this.download(this.model.filteredItems); + }); + + elements.enableChart.addEventListener("change", () => { + if (elements.enableChart.checked) elements.chart.classList.remove("hidden"); + else elements.chart.classList.add("hidden"); + }); + + elements.chart.addEventListener("change", (event) => { + event.stopPropagation(); }); // Cache in a field, so we don't have to call this.elements in each renderItem() call. @@ -87,6 +88,34 @@ class ItemsList extends View { }); } + set model(model) { + super.model = model; + this.elements.chart.model = model; + } + + get model() { + return super.model; + } + + download(items) { + const cleanedItems = []; + items.forEach((item) => { + cleanedItems.push({ + store: item.store, + id: item.id, + name: item.name, + price: item.price, + priceHistory: item.priceHistory, + isWeighted: item.isWeighted, + unit: item.unit, + quantity: item.quantity, + bio: item.bio, + url: item.url, + }); + }); + downloadJSON("items.json", cleanedItems); + } + sort(items) { const sortType = this.elements.sort.value; if (sortType == "price-asc") { @@ -127,6 +156,39 @@ class ItemsList extends View { } renderItem(item) { + if (!this._itemTemplate) { + this._itemTemplate = dom( + "tr", + /*html*/ ` + + +
+ + +
+ + + + + + + + + + + + + + + + + ` + ); + } + let quantity = item.quantity || ""; let unit = item.unit || ""; if (quantity >= 1000 && (unit == "g" || unit == "ml")) { @@ -170,6 +232,7 @@ class ItemsList extends View { "class", `item group ${stores[item.store]?.color} ${percentageChange > 0 ? "increased" : percentageChange < 0 ? "decreased" : "neutral"}` ); + itemDom.setAttribute("x-notraverse", "true"); const elements = View.elements(itemDom); elements.store.innerText = item.store; elements.name.href = item.url; @@ -200,6 +263,18 @@ class ItemsList extends View { } }); }); + + if (this._chart) { + elements.chartCheckbox.checked = item.chart; + elements.chartCheckbox.addEventListener("click", () => { + document.activeElement.blur(); + }); + elements.chartCheckbox.addEventListener("change", (event) => { + event.stopPropagation(); + item.chart = elements.chartCheckbox.checked; + this.elements.chart.render(); + }); + } if (this._showAllPriceHistories) elements.priceHistory.classList.remove("hidden"); return itemDom; } diff --git a/site/views/view.js b/site/views/view.js index 8208204..3093eb2 100644 --- a/site/views/view.js +++ b/site/views/view.js @@ -1,3 +1,5 @@ +const { getBooleanAttribute } = require("../misc"); + class View extends HTMLElement { constructor() { super(); @@ -26,9 +28,23 @@ class View extends HTMLElement { } static elements(view) { - const elements = view.querySelectorAll("[x-id]"); + let elements = [...view.querySelectorAll("[x-id]")]; + elements = elements.filter((el) => { + let parent = el.parentElement; + while (parent != view) { + if (parent instanceof View) return false; + if (getBooleanAttribute(parent, "x-notraverse")) return false; + parent = parent.parentElement; + } + return true; + }); const result = {}; - elements.forEach((element) => (result[element.getAttribute("x-id")] = element)); + elements.forEach((element) => { + if (result[element.getAttribute("x-id")]) { + console.log(`Duplicate element x-id ${element.getAttribute("x-id")} in ${view.localName}`); + } + result[element.getAttribute("x-id")] = element; + }); return result; }