191 lines
5.4 KiB
Svelte
191 lines
5.4 KiB
Svelte
<script>
|
|
import { onMount, tick } from "svelte";
|
|
import uPlot from "uplot";
|
|
import "uplot/dist/uPlot.min.css";
|
|
|
|
/** `{ date: ISO date string, amount: number }[]` */
|
|
export let series = [];
|
|
/** Total plot height in CSS px (axes + canvas). */
|
|
export let plotHeight = 204;
|
|
export let fmtMoney = (v, _currency) => String(v);
|
|
/** @type {string} */
|
|
export let currency = "RUB";
|
|
/** uPlot live legend: column header for the time (x) series */
|
|
export let legendTimeLabel = "Time";
|
|
/** uPlot live legend: column header for the value (y) series */
|
|
export let legendValueLabel = "Value";
|
|
|
|
let hostEl;
|
|
let plot;
|
|
let resizeObserver;
|
|
let syncTimer = 0;
|
|
/** Rebuild plot when legend copy changes (language), since series labels are init-only */
|
|
let builtLegendSig = "";
|
|
|
|
function readCssColor(name, fallback) {
|
|
if (typeof document === "undefined") return fallback;
|
|
const scope = hostEl || document.documentElement;
|
|
const raw = getComputedStyle(scope).getPropertyValue(name).trim();
|
|
return raw || fallback;
|
|
}
|
|
|
|
function parseDayUnix(iso) {
|
|
const s = String(iso || "");
|
|
const t = Date.parse(s.includes("T") ? s : `${s}T12:00:00Z`);
|
|
if (!Number.isFinite(t)) return 0;
|
|
return Math.floor(t / 1000);
|
|
}
|
|
|
|
function toAlignedData(rows) {
|
|
if (!rows?.length) return null;
|
|
const xs = rows.map((p) => parseDayUnix(p.date));
|
|
const ys = rows.map((p) => Number(p.amount) || 0);
|
|
return [xs, ys];
|
|
}
|
|
|
|
function yAxisTickLabels(values) {
|
|
return values.map((v) => fmtMoney(Number(v), currency));
|
|
}
|
|
|
|
/** uPlot passes already-formatted tick strings; reserve enough gutter so amounts are not clipped */
|
|
function yAxisGutterWidth(_u, values) {
|
|
const pad = 14;
|
|
const charPx = 6.1;
|
|
const maxChars = (values || []).reduce((m, v) => Math.max(m, String(v ?? "").length), 0);
|
|
return Math.min(104, Math.max(58, Math.ceil(pad + maxChars * charPx)));
|
|
}
|
|
|
|
/** Axis `size`: height (x / bottom) or width (y / left) in CSS px — only customize the y gutter */
|
|
function axisBandSize(_u, values, axisIdx) {
|
|
if (axisIdx !== 1) return 32;
|
|
return yAxisGutterWidth(_u, values);
|
|
}
|
|
|
|
function buildOpts(width) {
|
|
const w = Math.max(80, Math.floor(width));
|
|
const muted = readCssColor("--admin-muted", "#9aa7a2");
|
|
const border = readCssColor("--admin-border", "rgba(255,255,255,0.12)");
|
|
const accent = readCssColor("--accent", "#00fe7a");
|
|
const lineStroke = readCssColor(
|
|
"--admin-chart-stroke",
|
|
readCssColor("--admin-text", "#e8f0ec"),
|
|
);
|
|
const lineFill = readCssColor("--admin-chart-fill", "rgba(120, 140, 132, 0.14)");
|
|
|
|
return {
|
|
width: w,
|
|
height: plotHeight,
|
|
class: "admin-uplot",
|
|
pxAlign: true,
|
|
padding: [10, 12, 12, 10],
|
|
legend: {
|
|
show: true,
|
|
live: true,
|
|
markers: { show: true, width: 10, stroke: accent, fill: accent },
|
|
},
|
|
cursor: {
|
|
drag: { x: false, y: false },
|
|
points: { size: 7, width: 1, stroke: accent },
|
|
},
|
|
scales: {
|
|
x: { time: true },
|
|
y: { range: [0, null] },
|
|
},
|
|
series: [
|
|
{ label: legendTimeLabel },
|
|
{
|
|
label: legendValueLabel,
|
|
paths: uPlot.paths.spline(),
|
|
stroke: lineStroke,
|
|
width: 2,
|
|
cap: "round",
|
|
fill: lineFill,
|
|
},
|
|
],
|
|
axes: [
|
|
{
|
|
stroke: muted,
|
|
gap: 8,
|
|
grid: { show: true, stroke: border, width: 1 },
|
|
ticks: { stroke: border },
|
|
font: "11px system-ui,Segoe UI,sans-serif",
|
|
},
|
|
{
|
|
stroke: muted,
|
|
size: axisBandSize,
|
|
gap: 8,
|
|
grid: { show: true, stroke: border, width: 1 },
|
|
ticks: { stroke: border },
|
|
font: "10px system-ui,Segoe UI,sans-serif",
|
|
values: (u, ticks) => yAxisTickLabels(ticks),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function syncChart() {
|
|
if (!hostEl) return;
|
|
const d = toAlignedData(series);
|
|
const legendSig = `${legendTimeLabel}\0${legendValueLabel}`;
|
|
if (!d) {
|
|
plot?.destroy();
|
|
plot = undefined;
|
|
builtLegendSig = "";
|
|
return;
|
|
}
|
|
const w = Math.max(80, Math.floor(hostEl.clientWidth));
|
|
if (plot && builtLegendSig !== legendSig) {
|
|
plot.destroy();
|
|
plot = undefined;
|
|
}
|
|
if (!plot) {
|
|
plot = new uPlot(buildOpts(w), d, hostEl);
|
|
builtLegendSig = legendSig;
|
|
return;
|
|
}
|
|
plot.setData(d, true);
|
|
plot.setSize({ width: w, height: plotHeight });
|
|
}
|
|
|
|
function scheduleSync() {
|
|
if (typeof window === "undefined") return;
|
|
clearTimeout(syncTimer);
|
|
syncTimer = window.setTimeout(() => {
|
|
syncTimer = 0;
|
|
syncChart();
|
|
}, 0);
|
|
}
|
|
|
|
let rafId = 0;
|
|
|
|
onMount(() => {
|
|
rafId = requestAnimationFrame(() => {
|
|
void tick().then(() => {
|
|
scheduleSync();
|
|
if (!hostEl || typeof ResizeObserver === "undefined") return;
|
|
resizeObserver = new ResizeObserver(() => scheduleSync());
|
|
resizeObserver.observe(hostEl);
|
|
});
|
|
});
|
|
return () => {
|
|
cancelAnimationFrame(rafId);
|
|
clearTimeout(syncTimer);
|
|
resizeObserver?.disconnect();
|
|
resizeObserver = undefined;
|
|
plot?.destroy();
|
|
plot = undefined;
|
|
builtLegendSig = "";
|
|
};
|
|
});
|
|
|
|
$: if (hostEl) {
|
|
series;
|
|
plotHeight;
|
|
legendTimeLabel;
|
|
legendValueLabel;
|
|
scheduleSync();
|
|
}
|
|
</script>
|
|
|
|
<div class="admin-revenue-uplot-host" bind:this={hostEl}></div>
|