fix: resolve update charts blink
This commit is contained in:
+180
-159
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user