fix: resolve update charts blink

This commit is contained in:
2026-09-07 09:43:54 +05:00
parent 56359afa2d
commit af1481160c
2 changed files with 187 additions and 162 deletions
+180 -159
View File
@@ -14,19 +14,22 @@ const C = {
legend: "#8b8a83", legend: "#8b8a83",
}; };
type ChartCtor = new (ctx: CanvasRenderingContext2D, config: object) => unknown; type ChartCtor = new (ctx: CanvasRenderingContext2D, config: object) => ChartLike;
interface ChartLike {
data: { labels?: unknown[]; datasets: { data: number[] }[] };
update(mode?: string): void;
}
function getCtor(): ChartCtor | null { function getCtor(): ChartCtor | null {
const ChartClass = (window as unknown as { Chart?: unknown }).Chart; const ChartClass = (window as unknown as { Chart?: unknown }).Chart;
return ChartClass ? (ChartClass as ChartCtor) : null; return ChartClass ? (ChartClass as ChartCtor) : null;
} }
let instances: { destroy(): void }[] = []; let percentiles: ChartLike | null = null;
let perDay: ChartLike | null = null;
function dispose(): void { let wait: ChartLike | null = null;
for (const instance of instances) instance.destroy(); let percentilesWait = 0;
instances = [];
}
function tooltip(): Record<string, unknown> { function tooltip(): Record<string, unknown> {
return { return {
@@ -45,72 +48,166 @@ function buildRate(canvas: HTMLCanvasElement): void {
const ctor = getCtor(); const ctor = getCtor();
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctor || !ctx) return; if (!ctor || !ctx) return;
instances.push( new ctor(ctx, {
new ctor(ctx, { type: "doughnut",
type: "doughnut", data: {
data: { labels: ["HUMAN REPLIES"],
labels: ["HUMAN REPLIES"], datasets: [{ data: [0, 100], backgroundColor: [C.red, C.track], borderWidth: 0 }],
datasets: [{ data: [0, 100], backgroundColor: [C.red, C.track], borderWidth: 0 }], },
}, options: {
options: { responsive: true,
responsive: true, maintainAspectRatio: false,
maintainAspectRatio: false, cutout: "74%",
cutout: "74%", rotation: -90,
rotation: -90, circumference: 180,
circumference: 180, plugins: { legend: { display: false }, tooltip: { enabled: false } },
plugins: { legend: { display: false }, tooltip: { enabled: false } }, },
}, });
}),
);
} }
function buildPercentiles(canvas: HTMLCanvasElement, now: number): void { function buildPercentiles(canvas: HTMLCanvasElement, now: number): ChartLike | null {
const ctor = getCtor(); const ctor = getCtor();
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctor || !ctx) return; if (!ctor || !ctx) return null;
const wait = Number(daysFloat(now, CREATED_MS).toFixed(1)); const waitVal = Number(daysFloat(now, CREATED_MS).toFixed(1));
percentilesWait = waitVal;
const datasets = [ const datasets = [
{ label: "AVG", data: [wait, wait], borderColor: C.gold, borderWidth: 1, borderDash: [10, 6], pointRadius: 0, tension: 0 }, { label: "AVG", data: [waitVal, waitVal], borderColor: C.gold, borderWidth: 1, borderDash: [10, 6], pointRadius: 0, tension: 0 },
{ label: "P50", data: [wait, wait], borderColor: C.red, borderWidth: 1.5, pointRadius: 0, tension: 0 }, { label: "P50", data: [waitVal, waitVal], borderColor: C.red, borderWidth: 1.5, pointRadius: 0, tension: 0 },
{ label: "P90", data: [wait, wait], borderColor: C.redP90, borderWidth: 1.5, borderDash: [5, 4], pointRadius: 0, tension: 0 }, { label: "P90", data: [waitVal, waitVal], borderColor: C.redP90, borderWidth: 1.5, borderDash: [5, 4], pointRadius: 0, tension: 0 },
{ label: "P99", data: [wait, wait], borderColor: C.redP99, borderWidth: 1.5, borderDash: [2, 4], pointRadius: 0, tension: 0 }, { label: "P99", data: [waitVal, waitVal], borderColor: C.redP99, borderWidth: 1.5, borderDash: [2, 4], pointRadius: 0, tension: 0 },
]; ];
instances.push( return new ctor(ctx, {
new ctor(ctx, { type: "line",
type: "line", data: { labels: ["START", "TODAY"], datasets },
data: { labels: ["START", "TODAY"], datasets }, options: {
options: { responsive: true,
responsive: true, maintainAspectRatio: false,
maintainAspectRatio: false, interaction: { mode: "index", intersect: false },
interaction: { mode: "index", intersect: false }, plugins: {
plugins: { legend: { display: true, position: "bottom", align: "start", labels: { color: C.legend, boxWidth: 12, font: { family: FONT, size: 9 } } },
legend: { display: true, position: "bottom", align: "start", labels: { color: C.legend, boxWidth: 12, font: { family: FONT, size: 9 } } }, tooltip: {
tooltip: { ...tooltip(),
...tooltip(), callbacks: {
callbacks: { label: (item) => ` ${item.dataset.label || ""}: ${percentilesWait}d (SINGLE SAMPLE)`,
label: (item) => ` ${item.dataset.label || ""}: ${wait}d (SINGLE SAMPLE)`,
},
},
},
scales: {
x: { ticks: { color: C.axis, font: { family: FONT, size: 9 }, maxRotation: 0 }, grid: { color: C.grid }, border: { color: C.border } },
y: {
beginAtZero: true,
title: { display: true, text: "DAYS", color: C.axis, font: { family: FONT, size: 8 } },
ticks: { color: C.axis, font: { family: FONT, size: 9 } },
grid: { color: C.grid },
border: { color: C.border },
}, },
}, },
}, },
}), scales: {
); x: { ticks: { color: C.axis, font: { family: FONT, size: 9 }, maxRotation: 0 }, grid: { color: C.grid }, border: { color: C.border } },
y: {
beginAtZero: true,
title: { display: true, text: "DAYS", color: C.axis, font: { family: FONT, size: 8 } },
ticks: { color: C.axis, font: { family: FONT, size: 9 } },
grid: { color: C.grid },
border: { color: C.border },
},
},
},
});
} }
function buildPerDay(canvas: HTMLCanvasElement, now: number): void { function buildPerDay(canvas: HTMLCanvasElement): ChartLike | null {
const ctor = getCtor(); const ctor = getCtor();
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctor || !ctx) return; if (!ctor || !ctx) return null;
const axis = {
ticks: { color: C.axis, font: { family: FONT, size: 8 }, maxTicksLimit: 10, maxRotation: 0 },
grid: { color: C.grid },
border: { color: C.border },
};
return new ctor(ctx, {
type: "bar",
data: {
labels: [],
datasets: [
{ label: "MY MESSAGES", data: [], backgroundColor: C.gold, borderColor: C.gold, borderWidth: 1 },
{ label: "BOT REACTIONS", data: [], backgroundColor: C.muted, borderColor: C.muted, borderWidth: 1 },
{ label: "HUMAN REPLIES", data: [], backgroundColor: C.red, borderColor: C.red, borderWidth: 1 },
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: { display: true, position: "bottom", align: "start", labels: { color: C.legend, boxWidth: 12, font: { family: FONT, size: 9 } } },
tooltip: tooltip(),
},
scales: {
x: axis,
y: {
beginAtZero: true,
title: { display: true, text: "REPLIES", color: C.axis, font: { family: FONT, size: 8 } },
ticks: { color: C.axis, font: { family: FONT, size: 9 }, precision: 0 },
grid: { color: C.grid },
border: { color: C.border },
},
},
},
});
}
function buildWait(canvas: HTMLCanvasElement): ChartLike | null {
const ctor = getCtor();
const ctx = canvas.getContext("2d");
if (!ctor || !ctx) return null;
const grad = ctx.createLinearGradient(0, 0, 0, 200);
grad.addColorStop(0, "rgba(231,76,60,0.28)");
grad.addColorStop(1, "rgba(231,76,60,0)");
return new ctor(ctx, {
type: "line",
data: {
labels: [],
datasets: [
{
label: "DAYS WAITED",
data: [],
borderColor: C.red,
borderWidth: 1.5,
backgroundColor: grad,
fill: true,
tension: 0,
pointRadius: 0,
pointBackgroundColor: C.red,
pointHoverRadius: 3,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: { display: true, position: "bottom", align: "start", labels: { color: C.legend, boxWidth: 12, font: { family: FONT, size: 9 } } },
tooltip: tooltip(),
},
scales: {
x: {
ticks: { color: C.axis, font: { family: FONT, size: 8 }, maxTicksLimit: 10, maxRotation: 0 },
grid: { color: C.grid },
border: { color: C.border },
},
y: {
beginAtZero: true,
title: { display: true, text: "DAYS", color: C.axis, font: { family: FONT, size: 8 } },
ticks: { color: C.axis, font: { family: FONT, size: 9 } },
grid: { color: C.grid },
border: { color: C.border },
},
},
},
});
}
function updatePercentiles(chart: ChartLike, now: number): void {
const waitVal = Number(daysFloat(now, CREATED_MS).toFixed(1));
percentilesWait = waitVal;
for (const ds of chart.data.datasets) ds.data = [waitVal, waitVal];
chart.update();
}
function updatePerDay(chart: ChartLike, now: number): void {
const days = daysCeil(now); const days = daysCeil(now);
const mine: number[] = new Array(days).fill(0); const mine: number[] = new Array(days).fill(0);
const bot: number[] = new Array(days).fill(0); const bot: number[] = new Array(days).fill(0);
@@ -122,110 +219,34 @@ function buildPerDay(canvas: HTMLCanvasElement, now: number): void {
else bot[idx]++; else bot[idx]++;
} }
const labels = Array.from({ length: days }, (_, i) => `D${i}`); const labels = Array.from({ length: days }, (_, i) => `D${i}`);
const axis = { chart.data.labels = labels;
ticks: { color: C.axis, font: { family: FONT, size: 8 }, maxTicksLimit: 10, maxRotation: 0 }, for (let i = 0; i < chart.data.datasets.length; i++) {
grid: { color: C.grid }, chart.data.datasets[i].data = [mine, bot, human][i];
border: { color: C.border }, }
}; chart.update();
instances.push(
new ctor(ctx, {
type: "bar",
data: {
labels,
datasets: [
{ label: "MY MESSAGES", data: mine, backgroundColor: C.gold, borderColor: C.gold, borderWidth: 1 },
{ label: "BOT REACTIONS", data: bot, backgroundColor: C.muted, borderColor: C.muted, borderWidth: 1 },
{ label: "HUMAN REPLIES", data: human, backgroundColor: C.red, borderColor: C.red, borderWidth: 1 },
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: { display: true, position: "bottom", align: "start", labels: { color: C.legend, boxWidth: 12, font: { family: FONT, size: 9 } } },
tooltip: tooltip(),
},
scales: {
x: axis,
y: {
beginAtZero: true,
title: { display: true, text: "REPLIES", color: C.axis, font: { family: FONT, size: 8 } },
ticks: { color: C.axis, font: { family: FONT, size: 9 }, precision: 0 },
grid: { color: C.grid },
border: { color: C.border },
},
},
},
}),
);
} }
function buildWait(canvas: HTMLCanvasElement, now: number): void { function updateWait(chart: ChartLike, now: number): void {
const ctor = getCtor();
const ctx = canvas.getContext("2d");
if (!ctor || !ctx) return;
const days = daysCeil(now); const days = daysCeil(now);
const data = Array.from({ length: days }, (_, i) => i); chart.data.labels = Array.from({ length: days }, (_, i) => `D${i}`);
const labels = Array.from({ length: days }, (_, i) => `D${i}`); chart.data.datasets[0].data = Array.from({ length: days }, (_, i) => i);
const grad = ctx.createLinearGradient(0, 0, 0, 200); chart.update();
grad.addColorStop(0, "rgba(231,76,60,0.28)");
grad.addColorStop(1, "rgba(231,76,60,0)");
instances.push(
new ctor(ctx, {
type: "line",
data: {
labels,
datasets: [
{
label: "DAYS WAITED",
data,
borderColor: C.red,
borderWidth: 1.5,
backgroundColor: grad,
fill: true,
tension: 0,
pointRadius: 0,
pointBackgroundColor: C.red,
pointHoverRadius: 3,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: { display: true, position: "bottom", align: "start", labels: { color: C.legend, boxWidth: 12, font: { family: FONT, size: 9 } } },
tooltip: tooltip(),
},
scales: {
x: {
ticks: { color: C.axis, font: { family: FONT, size: 8 }, maxTicksLimit: 10, maxRotation: 0 },
grid: { color: C.grid },
border: { color: C.border },
},
y: {
beginAtZero: true,
title: { display: true, text: "DAYS", color: C.axis, font: { family: FONT, size: 8 } },
ticks: { color: C.axis, font: { family: FONT, size: 9 } },
grid: { color: C.grid },
border: { color: C.border },
},
},
},
}),
);
} }
export function renderCharts(now: number): void { export function initCharts(): void {
dispose();
const rate = document.querySelector<HTMLCanvasElement>("#chart-rate"); const rate = document.querySelector<HTMLCanvasElement>("#chart-rate");
const percentiles = document.querySelector<HTMLCanvasElement>("#chart-percentiles"); const p = document.querySelector<HTMLCanvasElement>("#chart-percentiles");
const perDay = document.querySelector<HTMLCanvasElement>("#chart-per-day"); const d = document.querySelector<HTMLCanvasElement>("#chart-per-day");
const wait = document.querySelector<HTMLCanvasElement>("#chart-wait"); const w = document.querySelector<HTMLCanvasElement>("#chart-wait");
if (rate) buildRate(rate); if (rate) buildRate(rate);
if (percentiles) buildPercentiles(percentiles, now); percentiles = p ? buildPercentiles(p, Date.now()) : null;
if (perDay) buildPerDay(perDay, now); perDay = d ? buildPerDay(d) : null;
if (wait) buildWait(wait, now); wait = w ? buildWait(w) : null;
updateCharts(Date.now());
}
export function updateCharts(now: number): void {
if (percentiles) updatePercentiles(percentiles, now);
if (perDay) updatePerDay(perDay, now);
if (wait) updateWait(wait, now);
} }
+7 -3
View File
@@ -121,7 +121,7 @@ const base = import.meta.env.BASE_URL.replace(/\/+$/, "");
fmtElapsed, fmtElapsed,
fmtDaysHours, fmtDaysHours,
} from "../data/caseData"; } from "../data/caseData";
import { renderCharts } from "../lib/dashboardCharts"; import { initCharts, updateCharts } from "../lib/dashboardCharts";
function renderLog(): void { function renderLog(): void {
const el = document.querySelector<HTMLElement>("#comments-log"); const el = document.querySelector<HTMLElement>("#comments-log");
@@ -165,7 +165,7 @@ const base = import.meta.env.BASE_URL.replace(/\/+$/, "");
function refresh(): void { function refresh(): void {
const updated = document.querySelector<HTMLElement>("#last-updated"); const updated = document.querySelector<HTMLElement>("#last-updated");
if (updated) updated.textContent = fmtStamp(new Date()); if (updated) updated.textContent = fmtStamp(new Date());
renderCharts(Date.now()); updateCharts(Date.now());
} }
const detailsLastComment = document.querySelector<HTMLElement>("#details-last-comment"); const detailsLastComment = document.querySelector<HTMLElement>("#details-last-comment");
@@ -183,7 +183,11 @@ const base = import.meta.env.BASE_URL.replace(/\/+$/, "");
renderLog(); renderLog();
renderTimeline(); renderTimeline();
tick(); tick();
refresh();
const lastUpdated = document.querySelector<HTMLElement>("#last-updated");
if (lastUpdated) lastUpdated.textContent = fmtStamp(new Date());
initCharts();
window.setInterval(tick, 1000); window.setInterval(tick, 1000);
window.setInterval(refresh, 60000); window.setInterval(refresh, 60000);