Binary compression (it's worse), unit prices in charts, small improvements.

This commit is contained in:
Mario Zechner 2023-06-16 16:01:13 +02:00
parent c97c8116f6
commit ea5c133003
9 changed files with 331 additions and 39 deletions

View File

@ -148,6 +148,139 @@ function sortItems(items) {
}); });
} }
function compressBinary(items) {
const buffer = [];
for (const item of items) {
// Serialize 'bio', 'isWeighted', and 'unit' into a single byte
let flagsByte = 0;
if (item.bio) flagsByte |= 1;
if (item.isWeighted) flagsByte |= 2;
if (item.unit === "ml") flagsByte |= 4;
if (item.unit === "stk") flagsByte |= 8;
buffer.push(flagsByte);
// Serialize 'quantity' as a 4-byte float
const quantityBuffer = Buffer.allocUnsafe(4);
quantityBuffer.writeFloatLE(item.quantity, 0);
buffer.push(...quantityBuffer);
// Serialize 'price' as a 4-byte float
const priceBuffer = Buffer.allocUnsafe(4);
priceBuffer.writeFloatLE(item.price, 0);
buffer.push(...priceBuffer);
// Serialize 'store' as a byte
const storeByte = STORE_KEYS.findIndex((store) => store == item.store);
buffer.push(storeByte);
// Serialize 'name' as UTF-8 with 2 bytes encoding the string length
const nameBuffer = Buffer.from(item.name, "utf8");
const nameLengthBuffer = Buffer.allocUnsafe(2);
nameLengthBuffer.writeUInt16LE(nameBuffer.length, 0);
buffer.push(...nameLengthBuffer, ...nameBuffer);
// Serialize 'url' as UTF-8 with 2 bytes encoding the string length
if (item.url !== undefined) {
const urlBuffer = Buffer.from(item.url, "utf8");
const urlLengthBuffer = Buffer.allocUnsafe(2);
urlLengthBuffer.writeUInt16LE(urlBuffer.length, 0);
buffer.push(...urlLengthBuffer, ...urlBuffer);
} else {
const urlLengthBuffer = Buffer.allocUnsafe(2).fill(0);
buffer.push(...urlLengthBuffer);
}
// Serialize 'priceHistory' array
const priceHistoryLengthBuffer = Buffer.allocUnsafe(2);
priceHistoryLengthBuffer.writeUInt16LE(item.priceHistory.length, 0);
buffer.push(...priceHistoryLengthBuffer);
for (const priceEntry of item.priceHistory) {
// Serialize price as a 4-byte float
const priceEntryBuffer = Buffer.allocUnsafe(4);
priceEntryBuffer.writeFloatLE(priceEntry.price, 0);
buffer.push(...priceEntryBuffer);
// Calculate the days since 2000-01-01
const entryDate = new Date(priceEntry.date);
const baseDate = new Date("2000-01-01");
const daysSince2000 = Math.floor((entryDate - baseDate) / (1000 * 60 * 60 * 24));
// Serialize days as a 32-bit integer
const daysBuffer = Buffer.allocUnsafe(4);
daysBuffer.writeInt32LE(daysSince2000, 0);
buffer.push(...daysBuffer);
}
}
return Buffer.from(buffer);
}
exports.compressBinary = compressBinary;
function decompressBinary(buffer) {
const objects = [];
let offset = 0;
while (offset < buffer.length) {
const obj = {};
// Deserialize 'bio', 'isWeighted', and 'unit' from the single byte
const flagsByte = buffer[offset++];
obj.bio = (flagsByte & 1) !== 0;
obj.isWeighted = (flagsByte & 2) !== 0;
obj.unit = (flagsByte & 4) !== 0 ? "ml" : (flagsByte & 8) !== 0 ? "stk" : "g";
// Deserialize 'quantity' as a 4-byte float
obj.quantity = buffer.readFloatLE(offset);
offset += 4;
// Deserialize 'price' as a 4-byte float
obj.price = buffer.readFloatLE(offset);
offset += 4;
// Deserialize 'store' as a byte
obj.store = STORE_KEYS[buffer[offset++]];
// Deserialize 'name' as UTF-8 with 2 bytes encoding the string length
const nameLength = buffer.readUInt16LE(offset);
offset += 2;
obj.name = buffer.toString("utf8", offset, offset + nameLength);
offset += nameLength;
// Deserialize 'url' as UTF-8 with 2 bytes encoding the string length (or undefined if length is 0)
const urlLength = buffer.readUInt16LE(offset);
offset += 2;
obj.url = urlLength !== 0 ? buffer.toString("utf8", offset, offset + urlLength) : undefined;
offset += urlLength;
// Deserialize 'priceHistory' array
const priceHistoryLength = buffer.readUInt16LE(offset);
offset += 2;
obj.priceHistory = [];
for (let i = 0; i < priceHistoryLength; i++) {
// Deserialize price as a 4-byte float
const price = buffer.readFloatLE(offset);
offset += 4;
// Deserialize days as a 32-bit integer
const daysSince2000 = buffer.readInt32LE(offset);
offset += 4;
// Calculate the date from days since 2000-01-01
const baseDate = new Date("2000-01-01");
const entryDate = new Date(baseDate.getTime() + daysSince2000 * 24 * 60 * 60 * 1000);
obj.priceHistory.push({ date: entryDate.toISOString().substring(0, 10), price });
}
objects.push(obj);
}
return objects;
}
// Keep this in sync with utils.js:decompress // Keep this in sync with utils.js:decompress
function compress(items) { function compress(items) {
const compressed = { const compressed = {

View File

@ -13,6 +13,7 @@ function copyItemsToSite(dataDir) {
for (const store of analysis.STORE_KEYS) { for (const store of analysis.STORE_KEYS) {
const storeItems = items.filter((item) => item.store === store); const storeItems = items.filter((item) => item.store === store);
analysis.writeJSON(`site/output/data/latest-canonical.${store}.compressed.json`, storeItems, false, 0, true); analysis.writeJSON(`site/output/data/latest-canonical.${store}.compressed.json`, storeItems, false, 0, true);
fs.writeFileSync(`site/output/data/latest-canonical.${store}.bin.json`, analysis.compressBinary(storeItems));
} }
} }

View File

@ -48,7 +48,7 @@ class CartHeader extends View {
} else { } else {
carts.push(cart); carts.push(cart);
} }
// model.carts.save(); models.carts.save();
location.href = location.pathname + "?name=" + encodeURIComponent(cart.name); location.href = location.pathname + "?name=" + encodeURIComponent(cart.name);
}); });
} }
@ -65,7 +65,7 @@ class CartHeader extends View {
for (const cartItem of cart.items) { for (const cartItem of cart.items) {
link += cartItem.store + cartItem.id + ";"; link += cartItem.store + cartItem.id + ";";
} }
elements.share.href = "cart.html?cart=" + link; elements.share.href = "cart.html?cart=" + link + (this.stateToUrl ? stateToUrl() : "");
} }
} }
} }
@ -150,6 +150,50 @@ function loadCart() {
cartList.classList.remove("hidden"); cartList.classList.remove("hidden");
}; };
const itemsFilter = cartFilter;
const itemsList = cartList;
const itemsChart = cartList.querySelector("items-chart");
itemsList.elements.sort.value = "store-and-name";
let baseUrl = location.href.split("&")[0];
const stateToUrl = () => {
const filterState = itemsFilter.shareableState;
const listState = itemsList.shareableState;
const chartState = itemsChart.shareableState;
const chartedItems = cart.filteredItems
.filter((item) => item.chart)
.map((item) => item.store + item.id)
.join(";");
return baseUrl + "&f=" + filterState + "&l=" + listState + "&c=" + chartState + "&d=" + chartedItems;
};
cartHeader.stateToUrl = stateToUrl;
itemsFilter.addEventListener("x-change", () => {
const url = stateToUrl();
history.pushState({}, null, url);
cartHeader.render();
});
itemsList.addEventListener("x-change", () => {
const url = stateToUrl();
history.pushState({}, null, url);
cartHeader.render();
});
const f = getQueryParameter("f");
const l = getQueryParameter("l");
const c = getQueryParameter("c");
const d = getQueryParameter("d");
if (f) itemsFilter.shareableState = f;
if (l) itemsList.shareableState = l;
if (c) itemsChart.shareableState = c;
if (d) {
cart.items.lookup = {};
for (const item of cart.items) cart.items.lookup[item.store + item.id] = item;
for (const id of d.split(";")) {
cart.items.lookup[id].chart = true;
}
}
cartList.model = cartFilter.model = cart; cartList.model = cartFilter.model = cart;
productsList.model = productsFilter.model = models.items; productsList.model = productsFilter.model = models.items;
if (c || d) itemsChart.render();
})(); })();

View File

@ -1,6 +1,78 @@
const { deltaTime, log } = require("../js/misc"); const { deltaTime, log } = require("../js/misc");
const { stores, STORE_KEYS } = require("./stores"); const { stores, STORE_KEYS } = require("./stores");
const { Model } = require("./model"); const { Model } = require("./model");
const { Settings } = require("./settings");
function decompressBinary(buffer) {
const objects = [];
let offset = 0;
const view = new DataView(buffer);
const baseDate = new Date("2000-01-01");
const textDecoder = new TextDecoder("utf-8");
while (offset < buffer.byteLength) {
const obj = {};
// Deserialize 'bio', 'isWeighted', and 'unit' from the single byte
const flagsByte = view.getUint8(offset++);
obj.bio = (flagsByte & 1) !== 0;
obj.isWeighted = (flagsByte & 2) !== 0;
obj.unit = (flagsByte & 4) !== 0 ? "ml" : (flagsByte & 8) !== 0 ? "stk" : "g";
// Deserialize 'quantity' as a 4-byte float
obj.quantity = view.getFloat32(offset, true);
offset += 4;
// Deserialize 'price' as a 4-byte float
obj.price = view.getFloat32(offset, true);
offset += 4;
// Deserialize 'store' as a byte
obj.store = STORE_KEYS[view.getUint8(offset++)];
// Deserialize 'name' as UTF-8 with 2 bytes encoding the string length
const nameLength = view.getUint16(offset, true);
offset += 2;
const nameBuffer = new Uint8Array(buffer, offset, nameLength);
obj.name = textDecoder.decode(nameBuffer);
offset += nameLength;
// Deserialize 'url' as UTF-8 with 2 bytes encoding the string length (or undefined if length is 0)
const urlLength = view.getUint16(offset, true);
offset += 2;
if (urlLength !== 0) {
const urlBuffer = new Uint8Array(buffer, offset, urlLength);
obj.url = textDecoder.decode(urlBuffer);
} else {
obj.url = undefined;
}
offset += urlLength;
// Deserialize 'priceHistory' array
const priceHistoryLength = view.getUint16(offset, true);
offset += 2;
obj.priceHistory = new Array(priceHistoryLength);
for (let i = 0; i < priceHistoryLength; i++) {
// Deserialize price as a 4-byte float
const price = view.getFloat32(offset, true);
offset += 4;
// Deserialize days as a 32-bit integer
const daysSince2000 = view.getInt32(offset, true);
offset += 4;
// Calculate the date from days since 2000-01-01
const entryDate = new Date(baseDate.getTime() + daysSince2000 * 24 * 60 * 60 * 1000);
obj.priceHistory[i] = { date: entryDate.toISOString().substring(0, 10), price };
}
objects.push(obj);
}
return objects;
}
function decompress(compressedItems) { function decompress(compressedItems) {
const storeLookup = compressedItems.stores; const storeLookup = compressedItems.stores;
@ -126,16 +198,31 @@ class Items extends Model {
async load() { async load() {
let start = performance.now(); let start = performance.now();
const settings = new Settings();
const compressedItemsPerStore = []; const compressedItemsPerStore = [];
for (const store of STORE_KEYS) { for (const store of STORE_KEYS) {
compressedItemsPerStore.push( compressedItemsPerStore.push(
new Promise(async (resolve) => { new Promise(async (resolve) => {
const start = performance.now(); let start = performance.now();
try { try {
const useJSON = settings.useJson;
if (useJSON) {
const response = await fetch(`data/latest-canonical.${store}.compressed.json`); const response = await fetch(`data/latest-canonical.${store}.compressed.json`);
const json = await response.json(); const json = await response.json();
log(`Items - loading compressed items for ${store} took ${deltaTime(start)} secs`); log(`Items - loading compressed items for ${store} took ${deltaTime(start)} secs`);
resolve(decompress(json)); start = performance.now();
let items = decompress(json);
log(`Items - Decompressing items for ${store} took ${deltaTime(start)} secs`);
resolve(items);
} else {
const response = await fetch(`data/latest-canonical.${store}.bin.json`);
const binary = await response.arrayBuffer();
log(`Items - loading compressed binary items for ${store} took ${deltaTime(start)} secs`);
start = performance.now();
let items = decompressBinary(binary);
log(`Items - Decompressing items for ${store} took ${deltaTime(start)} secs`);
resolve(items);
}
} catch (e) { } catch (e) {
log(`Items - error while loading compressed items for ${store} ${e.message}`); log(`Items - error while loading compressed items for ${store} ${e.message}`);
resolve([]); resolve([]);

View File

@ -1,7 +1,7 @@
const { STORE_KEYS, stores } = require("./stores"); const { STORE_KEYS, stores } = require("./stores");
const { Model } = require("./model"); const { Model } = require("./model");
export class Settings extends Model { class Settings extends Model {
constructor() { constructor() {
super(); super();
this.startDate = "2017-01-01"; this.startDate = "2017-01-01";
@ -9,6 +9,7 @@ export class Settings extends Model {
STORE_KEYS.forEach((store) => { STORE_KEYS.forEach((store) => {
this[store] = stores[store].defaultChecked; this[store] = stores[store].defaultChecked;
}); });
this.jsonData = true;
let settings = localStorage.getItem("settings"); let settings = localStorage.getItem("settings");
if (settings) { if (settings) {
@ -30,3 +31,5 @@ export class Settings extends Model {
localStorage.setItem("settings", JSON.stringify(settings)); localStorage.setItem("settings", JSON.stringify(settings));
} }
} }
exports.Settings = Settings;

View File

@ -37,6 +37,8 @@ class SettingsView extends View {
<option value="lines">Linien</option> <option value="lines">Linien</option>
</select> </select>
</label> </label>
<custom-checkbox x-id="useJson" x-change x-state checked label="Daten als JSON downloaden">
</custom-checkbox>
</div> </div>
`; `;
this.setupEventHandlers(); this.setupEventHandlers();

View File

@ -12,6 +12,7 @@ class ItemsChart extends View {
constructor() { constructor() {
super(); super();
this.unitPrice = false;
this.innerHTML = /*html*/ ` this.innerHTML = /*html*/ `
<div class="bg-stone-200 p-4 mx-auto"> <div class="bg-stone-200 p-4 mx-auto">
<div class="w-full h-[calc(100vw*0.66)] md:h-[calc(100vw*0.5)] lg:h-[calc(100vw*0.30)]" style="position: relative;"> <div class="w-full h-[calc(100vw*0.66)] md:h-[calc(100vw*0.5)] lg:h-[calc(100vw*0.30)]" style="position: relative;">
@ -41,9 +42,11 @@ class ItemsChart extends View {
calculateOverallPriceChanges(items, onlyToday, startDate, endDate) { calculateOverallPriceChanges(items, onlyToday, startDate, endDate) {
if (items.length == 0) return { dates: [], changes: [] }; if (items.length == 0) return { dates: [], changes: [] };
const getPrice = this.unitPrice ? (o) => o.unitPrice : (o) => o.price;
if (onlyToday) { if (onlyToday) {
let sum = 0; let sum = 0;
for (const item of items) sum += item.price; for (const item of items) sum += getPrice(item);
return [{ date: today(), price: sum }]; return [{ date: today(), price: sum }];
} }
@ -67,8 +70,8 @@ class ItemsChart extends View {
} }
for (let i = 0; i < uniqueDates.length; i++) { for (let i = 0; i < uniqueDates.length; i++) {
const priceObj = product.priceHistoryLookup[uniqueDates[i]]; const priceObj = product.priceHistoryLookup[uniqueDates[i]];
if (!price && priceObj) price = priceObj.price; if (!price && priceObj) price = getPrice(priceObj);
priceScratch[i] = priceObj ? priceObj.price : null; priceScratch[i] = priceObj ? getPrice(priceObj) : null;
} }
for (let i = 0; i < priceScratch.length; i++) { for (let i = 0; i < priceScratch.length; i++) {
@ -90,6 +93,7 @@ class ItemsChart extends View {
} }
renderChart(items, chartType) { renderChart(items, chartType) {
const getPrice = this.unitPrice ? (o) => o.unitPrice : (o) => o.price;
const canvasDom = this.elements.canvas; const canvasDom = this.elements.canvas;
const noData = this.elements.noData; const noData = this.elements.noData;
if (items.length === 0) { if (items.length === 0) {
@ -113,7 +117,7 @@ class ItemsChart extends View {
data: prices.map((price) => { data: prices.map((price) => {
return { return {
x: moment(price.date), x: moment(price.date),
y: price.price, y: getPrice(price),
}; };
}), }),
}; };
@ -212,11 +216,12 @@ class ItemsChart extends View {
log("ItemsChart - Calculating overall sum per store took " + ((performance.now() - now) / 1000).toFixed(2) + " secs"); log("ItemsChart - Calculating overall sum per store took " + ((performance.now() - now) / 1000).toFixed(2) + " secs");
} }
const getPrice = this.unitPrice ? (o) => o.unitPrice : (o) => o.price;
items.forEach((item) => { items.forEach((item) => {
if (item.chart) { if (item.chart) {
const chartItem = { const chartItem = {
name: item.store + " " + item.name, name: item.store + " " + item.name,
priceHistory: onlyToday ? [{ date: today(), price: item.price }] : item.priceHistory, priceHistory: onlyToday ? [{ date: today(), price: getPrice(item) }] : item.priceHistory,
}; };
itemsToShow.push(chartItem); itemsToShow.push(chartItem);
} }

View File

@ -129,7 +129,10 @@ class ItemsFilter extends View {
const start = performance.now(); const start = performance.now();
const elements = this.elements; const elements = this.elements;
this.model.totalItems = this.model.items.length; this.model.totalItems = this.model.items.length;
let filteredItems = [...this.model.items]; let filteredItems = new Array(this.model.items.length);
for (let i = 0; i < this.model.items.length; i++) {
filteredItems[i] = this.model.items[i];
}
let query = elements.query.value.trim(); let query = elements.query.value.trim();
if (query.length == 0 && this._emptyQuery) { if (query.length == 0 && this._emptyQuery) {
this.model.removeListener(this._listener); this.model.removeListener(this._listener);
@ -189,8 +192,7 @@ class ItemsFilter extends View {
// Don't apply store and misc filters if query is an alasql query. // Don't apply store and misc filters if query is an alasql query.
if (query.charAt(0) != "!") { if (query.charAt(0) != "!") {
if (this._filterByStores) { if (this._filterByStores) {
const checkedStores = this.checkedStores; filteredItems = filteredItems.filter((item) => elements[item.store].checked);
filteredItems = filteredItems.filter((item) => checkedStores.includes(item.store));
} }
if (this._filterByMisc) { if (this._filterByMisc) {
@ -228,6 +230,7 @@ class ItemsFilter extends View {
const start = performance.now(); const start = performance.now();
const elements = this.elements; const elements = this.elements;
const items = this.model.items; const items = this.model.items;
if (this._filterByPriceChanges) {
const dates = {}; const dates = {};
for (const item of items) { for (const item of items) {
if (item.priceHistory.length == 1) continue; if (item.priceHistory.length == 1) continue;
@ -251,12 +254,9 @@ class ItemsFilter extends View {
dateDom.innerText = `${date} (${dates[date]})`; dateDom.innerText = `${date} (${dates[date]})`;
priceChangesDates.append(dateDom); priceChangesDates.append(dateDom);
} }
log(`ItemsFilter - rendering items filter took ${deltaTime(start)}`);
} }
get checkedStores() { log(`ItemsFilter - rendering items filter took ${deltaTime(start)}`);
return STORE_KEYS.filter((store) => this.elements[store].checked);
} }
get shareableState() { get shareableState() {

View File

@ -5,6 +5,8 @@ const { View } = require("./view");
const { ItemsChart } = require("./items-chart"); const { ItemsChart } = require("./items-chart");
class ItemsList extends View { class ItemsList extends View {
static priceTypeId = 0;
constructor() { constructor() {
super(); super();
@ -27,8 +29,10 @@ class ItemsList extends View {
<custom-checkbox x-id="enableChart" x-change x-state label="Diagramm" class="${ <custom-checkbox x-id="enableChart" x-change x-state label="Diagramm" class="${
this._chart ? "" : "hidden" this._chart ? "" : "hidden"
}"></custom-checkbox> }"></custom-checkbox>
<label><input x-id="salesPrice" x-change x-state type="radio" name="priceType" checked> Verkaufspreis</label> <label><input x-id="salesPrice" x-change x-state type="radio" name="priceType${
<label><input x-id="unitPrice" x-change x-state type="radio" name="priceType"> Mengenpreis</label> ItemsList.priceTypeId
}" checked> Verkaufspreis</label>
<label><input x-id="unitPrice" x-change x-state type="radio" name="priceType${ItemsList.priceTypeId++}"> Mengenpreis</label>
</div> </div>
</div> </div>
<label class="${hideSort}"> <label class="${hideSort}">
@ -83,6 +87,18 @@ class ItemsList extends View {
elements.tableBody.querySelectorAll(".priceinfo").forEach((el) => (showAll ? el.classList.remove("hidden") : el.classList.add("hidden"))); elements.tableBody.querySelectorAll(".priceinfo").forEach((el) => (showAll ? el.classList.remove("hidden") : el.classList.add("hidden")));
}); });
elements.chart.unitPrice = elements.unitPrice.checked;
elements.unitPrice.addEventListener("change", () => {
elements.chart.unitPrice = elements.unitPrice.checked;
elements.chart.render();
});
elements.salesPrice.addEventListener("change", () => {
elements.chart.unitPrice = elements.unitPrice.checked;
elements.chart.render();
});
this.setupEventHandlers(); this.setupEventHandlers();
this.addEventListener("x-change", (event) => { this.addEventListener("x-change", (event) => {
@ -369,6 +385,7 @@ class ItemsList extends View {
const start = performance.now(); const start = performance.now();
const elements = this.elements; const elements = this.elements;
if (!this.model) return; if (!this.model) return;
elements.chart.unitPrice = elements.unitPrice.checked;
if (this.model.filteredItems.length != 0 && this.model.filteredItems.length <= (isMobile() ? 200 : 1500)) { if (this.model.filteredItems.length != 0 && this.model.filteredItems.length <= (isMobile() ? 200 : 1500)) {
elements.nameSimilarity.removeAttribute("disabled"); elements.nameSimilarity.removeAttribute("disabled");
} else { } else {