/* global module, require */ const { toSvgDataUri } = require("../../utils/svgDataUri"); const DEFAULT_WIDTH = 640; const DEFAULT_HEIGHT = 212; const DEFAULT_PADDING = 0; const AXIS_GUTTER = 32; const TOP_INSET = 6; const BOTTOM_INSET = 6; function normalizeSampleValue(value) { const numberValue = Number(value); if (!Number.isFinite(numberValue) || numberValue < 0) { return null; } return Math.round(numberValue); } /** * 所有诊断曲线都只保留固定窗口内的最近采样点, * 这样卡片高度稳定,也避免历史峰值把当前波动压扁。 */ function appendDiagnosticSample(samplesInput, value, maxPoints) { const normalizedValue = normalizeSampleValue(value); const next = Array.isArray(samplesInput) ? samplesInput.slice() : []; const sampleLimit = Math.max(1, Math.round(Number(maxPoints) || 30)); if (normalizedValue == null) { return next.slice(-sampleLimit); } next.push(normalizedValue); return next.slice(-sampleLimit); } function normalizeSeries(samplesInput) { return Array.isArray(samplesInput) ? samplesInput.map((sample) => normalizeSampleValue(sample)).filter((sample) => sample != null) : []; } function escapeXmlText(value) { return String(value || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** * 双轴图需要给左右刻度预留固定留白, * 否则数字标签会压到曲线和端点高亮。 */ function buildPlotFrame(width, height, padding) { const left = padding + AXIS_GUTTER; const right = width - padding - AXIS_GUTTER; const top = padding + TOP_INSET; const bottom = height - padding - BOTTOM_INSET; return { left, right, top, bottom, width: Math.max(1, right - left), height: Math.max(1, bottom - top) }; } /** * 两条曲线共用横轴,但保留各自真实量纲: * 左轴给“网关响应”,右轴给“网络时延”。 */ function buildSeriesScale(samples) { if (samples.length === 0) { return null; } const rawMin = Math.min(...samples); const rawMax = Math.max(...samples); const rawSpan = rawMax - rawMin; const padding = Math.max(4, rawSpan * 0.16, rawMax * 0.08, 1); const min = Math.max(0, rawMin - padding); const max = Math.max(min + 1, rawMax + padding); return { min, max }; } function buildAxisTicks(scale, plotFrame) { if (!scale) { return []; } const values = [scale.max, (scale.max + scale.min) / 2, scale.min]; return values.map((value, index) => { const ratio = values.length <= 1 ? 0 : index / (values.length - 1); return { value, y: plotFrame.top + plotFrame.height * ratio }; }); } function formatAxisTickLabel(value) { return `${Math.round(value)}ms`; } function buildChartPoints(samples, plotFrame, totalSlots, scale) { if (samples.length === 0 || !scale) { return []; } const safeRange = Math.max(1, scale.max - scale.min); const safeSlots = Math.max(samples.length, Math.round(Number(totalSlots) || samples.length), 2); const slotOffset = Math.max(0, safeSlots - samples.length); return samples.map((sample, index) => { const ratio = safeSlots <= 1 ? 0 : (slotOffset + index) / (safeSlots - 1); const x = plotFrame.left + plotFrame.width * ratio; const y = plotFrame.bottom - plotFrame.height * ((sample - scale.min) / safeRange); return { x, y }; }); } /** * 使用 Catmull-Rom 转三次贝塞尔,让时延曲线保持圆润, * 避免折线在采样点较少时显得生硬。 */ function buildSmoothLinePath(points) { if (points.length === 0) return ""; if (points.length === 1) { const point = points[0]; return `M ${point.x.toFixed(2)} ${point.y.toFixed(2)} L ${point.x.toFixed(2)} ${point.y.toFixed(2)}`; } let path = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`; for (let index = 0; index < points.length - 1; index += 1) { const p0 = points[index - 1] || points[index]; const p1 = points[index]; const p2 = points[index + 1]; const p3 = points[index + 2] || p2; const cp1x = p1.x + (p2.x - p0.x) / 6; const cp1y = p1.y + (p2.y - p0.y) / 6; const cp2x = p2.x - (p3.x - p1.x) / 6; const cp2y = p2.y - (p3.y - p1.y) / 6; path += ` C ${cp1x.toFixed(2)} ${cp1y.toFixed(2)} ${cp2x.toFixed(2)} ${cp2y.toFixed( 2 )} ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`; } return path; } function buildSmoothAreaPath(points, baselineY) { if (points.length === 0) return ""; const first = points[0]; const last = points[points.length - 1]; return `${buildSmoothLinePath(points)} L ${last.x.toFixed(2)} ${baselineY.toFixed(2)} L ${first.x.toFixed( 2 )} ${baselineY.toFixed(2)} Z`; } function buildGridLines(plotFrame, strokeColor) { const lines = []; for (let index = 0; index < 4; index += 1) { const y = plotFrame.top + (plotFrame.height / 3) * index; lines.push( `` ); } return lines.join(""); } function buildAxisLayer(side, ticks, plotFrame, lineColor, labelColor) { if (!Array.isArray(ticks) || ticks.length === 0) { return ""; } const isLeft = side === "left"; const axisX = isLeft ? plotFrame.left : plotFrame.right; const tickOuterX = isLeft ? axisX - 4 : axisX + 4; const labelX = isLeft ? axisX - 6 : axisX + 6; const anchor = isLeft ? "end" : "start"; const tickMarks = ticks .map( (tick) => ` ${escapeXmlText( formatAxisTickLabel(tick.value) )} ` ) .join(""); return ` ${tickMarks} `; } function buildEmptySparklineSvg(width, height, plotFrame, colors) { const midY = (plotFrame.top + plotFrame.bottom) * 0.5; const leftY = midY - 18; const rightY = midY + 18; return ` ${buildGridLines(plotFrame, colors.grid)} `; } function buildSeriesDefs(prefix, colors) { return ` `; } function buildSeriesLayers(points, colors, prefix, baselineY) { if (points.length === 0) { return ""; } const linePath = buildSmoothLinePath(points); const areaPath = buildSmoothAreaPath(points, baselineY); const lastPoint = points[points.length - 1]; return ` `; } /** * 双序列图把“网关响应”和“网络时延”画在同一张坐标图里: * 1. 横轴按采样槽位右对齐,保证两个序列的最新点处在同一时间位置; * 2. 左轴保留“网关响应”自身量纲,右轴保留“网络时延”自身量纲; * 3. 两条曲线继续使用平滑贝塞尔,避免采样点少时退化成生硬折线。 */ function buildCombinedDiagnosticSparkline(seriesInput, options) { const responseSamples = normalizeSeries(seriesInput && seriesInput.responseSamples); const networkSamples = normalizeSeries(seriesInput && seriesInput.networkSamples); const width = Math.max(120, Math.round(Number(options && options.width) || DEFAULT_WIDTH)); const height = Math.max(80, Math.round(Number(options && options.height) || DEFAULT_HEIGHT)); const padding = Math.max(0, Math.round(Number(options && options.padding) || DEFAULT_PADDING)); const plotFrame = buildPlotFrame(width, height, padding); const colors = { responseLine: (options && options.responseLineColor) || "#67D1FF", responseFill: (options && options.responseFillColor) || "#67D1FF", responseGlow: (options && options.responseGlowColor) || "#B7F1FF", networkLine: (options && options.networkLineColor) || "#FFB35C", networkFill: (options && options.networkFillColor) || "#FFB35C", networkGlow: (options && options.networkGlowColor) || "#FFE0A3", cardGlow: (options && options.cardGlowColor) || "rgba(103, 209, 255, 0.28)", grid: (options && options.gridColor) || "rgba(255, 255, 255, 0.12)" }; if (responseSamples.length === 0 && networkSamples.length === 0) { return toSvgDataUri(buildEmptySparklineSvg(width, height, plotFrame, colors)); } const responseScale = buildSeriesScale(responseSamples); const networkScale = buildSeriesScale(networkSamples); const responseTicks = buildAxisTicks(responseScale, plotFrame); const networkTicks = buildAxisTicks(networkScale, plotFrame); const totalSlots = Math.max(responseSamples.length, networkSamples.length, 2); const responsePoints = buildChartPoints(responseSamples, plotFrame, totalSlots, responseScale); const networkPoints = buildChartPoints(networkSamples, plotFrame, totalSlots, networkScale); const svg = ` ${buildSeriesDefs("response", { line: colors.responseLine, fill: colors.responseFill, glow: colors.responseGlow })} ${buildSeriesDefs("network", { line: colors.networkLine, fill: colors.networkFill, glow: colors.networkGlow })} ${buildGridLines(plotFrame, colors.grid)} ${buildAxisLayer("left", responseTicks, plotFrame, colors.responseLine, colors.responseGlow)} ${buildAxisLayer("right", networkTicks, plotFrame, colors.networkLine, colors.networkGlow)} ${buildSeriesLayers( responsePoints, { line: colors.responseLine, fill: colors.responseFill, glow: colors.responseGlow }, "response", plotFrame.bottom )} ${buildSeriesLayers( networkPoints, { line: colors.networkLine, fill: colors.networkFill, glow: colors.networkGlow }, "network", plotFrame.bottom )} `; return toSvgDataUri(svg); } module.exports = { appendDiagnosticSample, buildCombinedDiagnosticSparkline };