Files
dgtlmoon 71d332d5a0
Build and push containers / metadata (push) Has been cancelled
Build and push containers / build-push-containers (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
UI full refactor / upgrade (#4183)
2026-07-16 15:08:29 +02:00

369 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Restock price/stock timeline - a hand-rolled smoothed SVG line graph.
// Line segments are green where the item was in stock and red where it was out of stock.
//
// Two consumers:
// * the restock diff page (#restock-graph), which fetches window.restock_data_url and also
// builds the data table below it; and
// * the watchlist inline roll-down, which calls window.renderRestockGraph(el, series, currency, i18n).
//
// Series shape (oldest -> newest): [{timestamp: <epoch sec>, price: <float|null>, in_stock: <bool|null>}]
(function ($) {
'use strict';
const SVG_NS = 'http://www.w3.org/2000/svg';
const COLOR_IN = '#1fa463'; // in stock = green
const COLOR_OUT = '#e74c3c'; // out of stock = red
const COLOR_UNKNOWN = '#999'; // stock state not known
const HEIGHT = 320; // default (diff page); the inline watchlist graph passes a shorter one
const PAD = { top: 20, right: 16, bottom: 30, left: 52 };
const MAX_X_LABELS = 6; // keep the date axis uncluttered
const DEFAULT_I18N = { in_stock: 'In stock', out_of_stock: 'Out of stock',
no_data: 'No price data available to graph yet.', load_error: 'Could not load price history.',
changes: 'Changes', avg_price: 'Average price',
price_low: 'Currently low', price_typical: 'Currently typical', price_high: 'Currently high',
cheaper_than: 'cheaper than %s% of tracked prices',
pricier_than: 'more expensive than %s% of tracked prices',
typical_note: 'around the usual price', avg_label: 'avg' };
const OVERLAY_MIN_POINTS = 5; // need enough history for low/typical/high to be meaningful
function el(name, attrs) {
const e = document.createElementNS(SVG_NS, name);
for (const k in attrs) e.setAttribute(k, attrs[k]);
return e;
}
function stockColor(inStock) {
if (inStock === true) return COLOR_IN;
if (inStock === false) return COLOR_OUT;
return COLOR_UNKNOWN;
}
// X-axis label: day + short month + short year, e.g. "21 Jun '25".
function fmtAxisDate(epoch) {
const d = new Date(epoch * 1000);
const dm = d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
return `${dm} '${String(d.getFullYear()).slice(-2)}`;
}
function fmtDateTime(epoch) {
// undefined locale => the browser's locale; explicit options for a clean, complete format.
return new Date(epoch * 1000).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
function fmtPrice(v, currency) {
const n = Math.round(v * 100) / 100;
return (currency || '') + (Number.isInteger(n) ? n : n.toFixed(2));
}
// Thin a large price series down to ~target points for drawing (you can't perceive more
// points than pixels). Always keeps the first, last and the true min/max points so the line
// still touches its extremes. The axis range + summary stay computed over the FULL data.
function downsample(arr, target) {
if (arr.length <= target) return arr;
const keep = new Set([0, arr.length - 1]);
let minI = 0, maxI = 0;
for (let i = 1; i < arr.length; i++) {
if (arr[i].price < arr[minI].price) minI = i;
if (arr[i].price > arr[maxI].price) maxI = i;
}
keep.add(minI); keep.add(maxI);
const step = arr.length / target;
for (let i = 0; i < target; i++) keep.add(Math.floor(i * step));
return Array.from(keep).sort((a, b) => a - b).map(i => arr[i]);
}
// Monotone cubic spline (FritschCarlson). A gentle, smooth curve that — unlike Catmull-Rom —
// is guaranteed NOT to overshoot beyond the actual data points: tangents are clamped so the
// line never dips below / rises above neighbouring values. So the curve stays faithful to the
// real prices (no phantom dips/spikes) while still looking soft. Returns an SVG path string.
function monotonePath(pts) {
const n = pts.length;
if (n < 2) return '';
const xs = pts.map(p => p.x), ys = pts.map(p => p.y);
if (n === 2) return `M ${xs[0].toFixed(1)} ${ys[0].toFixed(1)} L ${xs[1].toFixed(1)} ${ys[1].toFixed(1)}`;
const dx = [], slope = [];
for (let i = 0; i < n - 1; i++) {
const h = xs[i + 1] - xs[i];
dx.push(h);
slope.push(h === 0 ? 0 : (ys[i + 1] - ys[i]) / h);
}
const m = new Array(n);
m[0] = slope[0];
m[n - 1] = slope[n - 2];
for (let i = 1; i < n - 1; i++) {
// Local extremum (slope changes sign) → flat tangent so the curve doesn't overshoot.
m[i] = (slope[i - 1] * slope[i] <= 0) ? 0 : (slope[i - 1] + slope[i]) / 2;
}
// FritschCarlson: clamp tangents so each segment stays monotone (no overshoot).
for (let i = 0; i < n - 1; i++) {
if (slope[i] === 0) { m[i] = 0; m[i + 1] = 0; continue; }
const a = m[i] / slope[i], b = m[i + 1] / slope[i], s = a * a + b * b;
if (s > 9) {
const t = 3 / Math.sqrt(s);
m[i] = t * a * slope[i];
m[i + 1] = t * b * slope[i];
}
}
let d = `M ${xs[0].toFixed(1)} ${ys[0].toFixed(1)}`;
for (let i = 0; i < n - 1; i++) {
const h = dx[i];
const c1x = xs[i] + h / 3, c1y = ys[i] + m[i] * h / 3;
const c2x = xs[i + 1] - h / 3, c2y = ys[i + 1] - m[i + 1] * h / 3;
d += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)} ${c2x.toFixed(1)} ${c2y.toFixed(1)} ${xs[i + 1].toFixed(1)} ${ys[i + 1].toFixed(1)}`;
}
return d;
}
function draw($container, series, currency, i18n, summary, height) {
height = height || HEIGHT;
// Only points that actually have a price can sit on the line.
const data = (series || []).filter(p => p.price !== null && p.price !== undefined);
if (data.length < 2) {
$container.html($('<p class="pure-form-message-inline"></p>').text(i18n.no_data));
return;
}
const width = Math.max($container.width() || 0, 320);
const innerH = height - PAD.top - PAD.bottom;
const prices = data.map(p => p.price);
let dataMin = Math.min.apply(null, prices), dataMax = Math.max.apply(null, prices);
if (dataMin === dataMax) { dataMin -= 1; dataMax += 1; }
// Pad the value domain ~10% top & bottom so the (smoothed, possibly overshooting) line
// and the min/max labels sit inside the plot area instead of being clipped at the edges.
const vpad = (dataMax - dataMin) * 0.1;
const domMin = dataMin - vpad, domMax = dataMax + vpad, range = domMax - domMin;
const ticks = [dataMax, (dataMin + dataMax) / 2, dataMin];
// Widen the left gutter to fit the price labels (e.g. "CZK12.34") so a long currency
// prefix isn't clipped at the SVG's left edge. ~7px/char + padding.
const maxLabelChars = Math.max.apply(null, ticks.map(v => fmtPrice(v, currency).length));
const padLeft = Math.max(PAD.left, maxLabelChars * 7 + 14);
const innerW = width - padLeft - PAD.right;
// Draw at most ~one point per few pixels; keeps the SVG light on long histories / mobile.
const drawData = downsample(data, Math.max(40, Math.min(250, Math.floor(innerW / 4))));
const x = i => padLeft + (i * innerW) / (drawData.length - 1);
const y = price => PAD.top + (1 - (price - domMin) / range) * innerH;
const pts = drawData.map((p, i) => ({ x: x(i), y: y(p.price), d: p }));
// Size the SVG in real pixels at the measured width (no viewBox => no CSS rescaling).
const svg = el("svg", { width: width, height: height, role: "img" });
// Y axis: max / mid / min gridlines + price labels.
ticks.forEach(price => {
const yy = y(price);
svg.appendChild(el('line', { class: 'rg-axis', x1: padLeft, y1: yy, x2: width - PAD.right, y2: yy, opacity: price === dataMin ? 0.6 : 0.15 }));
const t = el('text', { class: 'rg-label', x: padLeft - 8, y: yy + 4, 'text-anchor': 'end' });
t.textContent = fmtPrice(price, currency);
svg.appendChild(t);
});
// Analyser overlays (Google-Flights style): a "typical range" band (p25-p75) behind the
// line + a dashed average line. Drawn before the data line so they sit behind it.
const showOverlays = summary && summary.count >= OVERLAY_MIN_POINTS;
if (showOverlays) {
const yTop = y(summary.p75), yBot = y(summary.p25);
svg.appendChild(el('rect', {
class: 'rg-band', x: padLeft, y: Math.min(yTop, yBot),
width: Math.max(0, width - PAD.right - padLeft), height: Math.abs(yBot - yTop)
}));
const yAvg = y(summary.avg);
svg.appendChild(el('line', {
class: 'rg-avg-line', x1: padLeft, y1: yAvg, x2: width - PAD.right, y2: yAvg,
'stroke-dasharray': '4 4'
}));
const at = el('text', { class: 'rg-label rg-avg-text', x: width - PAD.right, y: yAvg - 4, 'text-anchor': 'end' });
at.textContent = i18n.avg_label || 'avg';
svg.appendChild(at);
}
// Single neutral, gently-smoothed line (monotone cubic — soft but never overshoots the
// real values). Stock status is shown by the DOT colours instead, not the line.
svg.appendChild(el('path', {
class: 'rg-line', d: monotonePath(pts), fill: 'none',
'stroke-width': 2, 'stroke-linecap': 'round', 'stroke-linejoin': 'round'
}));
// Dots, coloured by each point's own stock state (green = in stock, red = out of stock).
// Sized to match the legend swatch (~9px) with a thin surface-coloured ring so they stand
// out from the line instead of blending into it.
pts.forEach(p => {
svg.appendChild(el('circle', {
class: 'rg-dot', cx: p.x.toFixed(1), cy: p.y.toFixed(1), r: 4.5, fill: stockColor(p.d.in_stock)
}));
});
// Sparse x-axis date labels: evenly spaced, always including both endpoints. The number
// adapts to the available width (each label ~64px) so they don't jam together on narrow
// / mobile widths, capped at MAX_X_LABELS on desktop. Min 2 (the two endpoints).
const fitLabels = Math.max(2, Math.min(MAX_X_LABELS, Math.floor(innerW / 64)));
const count = Math.min(fitLabels, pts.length);
const labelIdx = Array.from(new Set(
Array.from({ length: count }, (_, i) => Math.round((i * (pts.length - 1)) / (count - 1)))
));
labelIdx.forEach(i => {
const anchor = i === 0 ? 'start' : (i === pts.length - 1 ? 'end' : 'middle');
const t = el('text', { class: 'rg-label', x: pts[i].x.toFixed(1), y: height - 10, 'text-anchor': anchor });
t.textContent = fmtAxisDate(pts[i].d.timestamp);
svg.appendChild(t);
});
$container.empty().append(svg);
// --- Hover tooltip: a generous transparent hit-circle per point shows price + date ---
const $tip = $('<div class="rg-tooltip"></div>').appendTo($container);
pts.forEach(p => {
const state = p.d.in_stock === null ? '' :
(p.d.in_stock ? ' &middot; ' + i18n.in_stock : ' &middot; ' + i18n.out_of_stock);
const hit = el('circle', { cx: p.x.toFixed(1), cy: p.y.toFixed(1), r: 14, fill: 'transparent', style: 'cursor: pointer;' });
svg.appendChild(hit);
$(hit).on('mouseenter mousemove', function (e) {
const r = $container[0].getBoundingClientRect();
const cx = e.clientX - r.left, cy = e.clientY - r.top;
$tip.html('<strong>' + fmtPrice(p.d.price, currency) + '</strong>' + state + '<br>' + fmtDateTime(p.d.timestamp)).show();
const tipW = $tip.outerWidth(), GAP = 14;
// Default to the right of the cursor; flip left if it would overflow the right edge.
let left = cx + GAP;
if (left + tipW > $container.width()) left = cx - GAP - tipW;
if (left < 0) left = 0;
$tip.css({ left: left + 'px', top: cy + 'px' });
}).on('mouseleave', function () {
$tip.hide();
});
});
// Legend explaining the dot colours (the line is neutral; only dots indicate stock).
const $legend = $('<div class="rg-legend"></div>');
$legend.append($('<span class="rg-legend-item"></span>')
.append('<span class="rg-legend-dot in"></span>').append(document.createTextNode(' ' + (i18n.in_stock || 'In stock'))));
$legend.append($('<span class="rg-legend-item"></span>')
.append('<span class="rg-legend-dot out"></span>').append(document.createTextNode(' ' + (i18n.out_of_stock || 'Out of stock'))));
$container.append($legend);
// Header row above the graph: status pill (LOW / TYPICAL / HIGH, left) and the summary
// stats (right), on one line. The pill sub-text is phrased per status so it never reads
// awkwardly (e.g. "cheaper than 0%" when high).
const $header = $('<div class="rg-header"></div>');
let $pill = $('<span></span>'); // placeholder keeps the stats pushed to the right
if (showOverlays) {
$pill = $('<div class="rg-status rg-status-' + summary.status + '"></div>');
$pill.append($('<span class="rg-status-label"></span>').text(i18n['price_' + summary.status] || ''));
let sub;
if (summary.status === 'low') {
sub = (i18n.cheaper_than || '').replace('%s', summary.cheaper_than_pct);
} else if (summary.status === 'high') {
sub = (i18n.pricier_than || '').replace('%s', summary.pricier_than_pct);
} else {
sub = i18n.typical_note || '';
}
if (sub) $pill.append($('<span class="rg-status-sub"></span>').text(sub));
}
const avgPrice = prices.reduce((a, b) => a + b, 0) / prices.length;
const $stats = $('<div class="rg-stats"></div>')
.text((i18n.avg_price || 'Average price') + ' ' + fmtPrice(avgPrice, currency) +
', ' + (series ? series.length : data.length) + ' ' + (i18n.changes || 'Changes'));
$header.append($pill).append($stats);
$container.prepend($header);
}
function buildTable($table, series, currency, i18n) {
const $tbody = $table.find('tbody');
if (!$tbody.length) return;
$tbody.empty();
series.slice().reverse().forEach(p => {
let stock = '-';
if (p.in_stock === true) stock = $('<span class="restock-badge in-stock"></span>').text(i18n.in_stock);
else if (p.in_stock === false) stock = $('<span class="restock-badge out-of-stock"></span>').text(i18n.out_of_stock);
const price = (p.price !== null && p.price !== undefined) ? fmtPrice(p.price, currency) : '-';
$('<tr></tr>')
.append($('<td></td>').text(fmtDateTime(p.timestamp)))
.append($('<td></td>').append(stock))
.append($('<td></td>').text(price))
.appendTo($tbody);
});
}
// Redraw a graph only if its container width actually changed (skips redundant redraws and
// prevents ResizeObserver feedback loops).
function redraw($c) {
const ctx = $c.data('rg');
if (!ctx) return;
const w = Math.round($c.width());
if (w === ctx.lastWidth) return;
ctx.lastWidth = w;
draw($c, ctx.series, ctx.currency, ctx.i18n, ctx.summary, ctx.height);
}
// Watch the container's own size, not just the window: the content area also changes width
// when the action sidebar expands on hover (no window 'resize' event fires for that).
const hasRO = typeof ResizeObserver !== 'undefined';
let ro = null;
if (hasRO) {
let roTimer;
const pending = new Set();
ro = new ResizeObserver(function (entries) {
entries.forEach(e => pending.add(e.target));
clearTimeout(roTimer);
roTimer = setTimeout(function () {
pending.forEach(t => redraw($(t)));
pending.clear();
}, 120);
});
} else {
// Fallback for browsers without ResizeObserver: window resize only.
let resizeTimer;
$(window).on('resize.restockgraph', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
$('.js-restock-graph').each(function () { redraw($(this)); });
}, 150);
});
}
// Public reusable renderer. Stores its context on the element and observes the element so
// it redraws on any size change; usable from anywhere (e.g. the watchlist inline roll-down).
window.renderRestockGraph = function (container, series, currency, i18n, summary, height) {
const $c = $(container);
if (!$c.length) return;
const merged = $.extend({}, DEFAULT_I18N, i18n || {});
// Seed lastWidth to the current width so the immediate ResizeObserver callback (which
// fires once on observe()) doesn't trigger a redundant redraw.
$c.addClass('js-restock-graph').data('rg', {
series: series || [], currency: currency || '', i18n: merged, summary: summary || null,
height: height || HEIGHT, lastWidth: Math.round($c.width())
});
draw($c, series || [], currency || '', merged, summary || null, height || HEIGHT);
if (ro) {
try { ro.unobserve($c[0]); } catch (e) {}
ro.observe($c[0]);
}
};
// Diff page bootstrap: fetch the timeline, render the graph + the data table.
$(function () {
const $container = $('#restock-graph');
if (!$container.length || !window.restock_data_url) return;
const i18n = $.extend({}, DEFAULT_I18N, window.restock_i18n || {});
$.getJSON(window.restock_data_url).done(function (data) {
const series = (data && data.series) || [];
window.renderRestockGraph($container, series, (data && data.currency) || '', i18n, (data && data.summary) || null);
buildTable($('#restock-history-table'), series, (data && data.currency) || '', i18n);
}).fail(function () {
$container.html($('<p class="pure-form-message-inline"></p>').text(i18n.load_error));
});
});
})(jQuery);