Sparkline.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. <script setup>
  2. import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
  3. const props = defineProps({
  4. data: { type: Array, required: true },
  5. labels: { type: Array, default: () => [] },
  6. vbWidth: { type: Number, default: 320 },
  7. height: { type: Number, default: 80 },
  8. stroke: { type: String, default: '#008771' },
  9. strokeWidth: { type: Number, default: 2 },
  10. maxPoints: { type: Number, default: 120 },
  11. showGrid: { type: Boolean, default: true },
  12. gridColor: { type: String, default: 'rgba(0,0,0,0.1)' },
  13. fillOpacity: { type: Number, default: 0.15 },
  14. showMarker: { type: Boolean, default: true },
  15. markerRadius: { type: Number, default: 2.8 },
  16. showAxes: { type: Boolean, default: false },
  17. yTickStep: { type: Number, default: 25 },
  18. tickCountX: { type: Number, default: 4 },
  19. paddingLeft: { type: Number, default: 32 },
  20. paddingRight: { type: Number, default: 6 },
  21. paddingTop: { type: Number, default: 6 },
  22. paddingBottom: { type: Number, default: 20 },
  23. showTooltip: { type: Boolean, default: false },
  24. // Value-range customization. When valueMax is null the chart auto-scales
  25. // to the running max of the data (useful for unbounded series like
  26. // network throughput or online clients). Defaults preserve the legacy
  27. // 0..100 percent behavior so existing callers don't need to change.
  28. valueMin: { type: Number, default: 0 },
  29. valueMax: { type: [Number, null], default: 100 },
  30. // Y-axis tick formatter. Receives the raw value, returns the label.
  31. // tooltipFormatter formats the hover-readout; falls back to yFormatter.
  32. yFormatter: { type: Function, default: (v) => `${Math.round(v)}%` },
  33. tooltipFormatter: { type: Function, default: null },
  34. });
  35. const hoverIdx = ref(-1);
  36. // Measured CSS width of the SVG. Drives the viewBox so SVG units stay
  37. // 1:1 with rendered pixels — otherwise `preserveAspectRatio="none"`
  38. // stretches the X axis and squashes axis text horizontally on narrow
  39. // containers (mobile). Falls back to the prop until the first measure.
  40. const svgRef = ref(null);
  41. const measuredWidth = ref(0);
  42. const effectiveVbWidth = computed(() => measuredWidth.value > 0 ? measuredWidth.value : props.vbWidth);
  43. let resizeObserver = null;
  44. function measure() {
  45. const el = svgRef.value;
  46. if (!el) return;
  47. const w = el.getBoundingClientRect?.().width || 0;
  48. if (w > 0) measuredWidth.value = Math.round(w);
  49. }
  50. onMounted(() => {
  51. measure();
  52. if (typeof ResizeObserver !== 'undefined' && svgRef.value) {
  53. resizeObserver = new ResizeObserver(measure);
  54. resizeObserver.observe(svgRef.value);
  55. } else {
  56. window.addEventListener('resize', measure);
  57. }
  58. });
  59. onBeforeUnmount(() => {
  60. if (resizeObserver) resizeObserver.disconnect();
  61. else window.removeEventListener('resize', measure);
  62. });
  63. const viewBoxAttr = computed(() => `0 0 ${effectiveVbWidth.value} ${props.height}`);
  64. const drawWidth = computed(() => Math.max(1, effectiveVbWidth.value - props.paddingLeft - props.paddingRight));
  65. const drawHeight = computed(() => Math.max(1, props.height - props.paddingTop - props.paddingBottom));
  66. const nPoints = computed(() => Math.min(props.data.length, props.maxPoints));
  67. const dataSlice = computed(() => {
  68. const n = nPoints.value;
  69. if (n === 0) return [];
  70. return props.data.slice(props.data.length - n);
  71. });
  72. const labelsSlice = computed(() => {
  73. const n = nPoints.value;
  74. if (!props.labels?.length || n === 0) return [];
  75. const start = Math.max(0, props.labels.length - n);
  76. return props.labels.slice(start);
  77. });
  78. // Resolved domain. When valueMax is null we auto-scale; pad the upper
  79. // bound by 10% so the line never touches the top edge — looks more
  80. // natural and gives the axis a sane ceiling. Floor the dynamic range
  81. // at 1 to avoid divide-by-zero on flat-line data (e.g. all zeros).
  82. const yDomain = computed(() => {
  83. const min = props.valueMin;
  84. if (props.valueMax != null) return { min, max: props.valueMax };
  85. let max = min;
  86. for (const v of dataSlice.value) {
  87. const n = Number(v);
  88. if (Number.isFinite(n) && n > max) max = n;
  89. }
  90. if (max <= min) max = min + 1;
  91. return { min, max: max * 1.1 };
  92. });
  93. function project(v) {
  94. const { min, max } = yDomain.value;
  95. const span = max - min;
  96. if (span <= 0) return props.paddingTop + drawHeight.value;
  97. const clipped = Math.max(min, Math.min(max, Number(v) || 0));
  98. const ratio = (clipped - min) / span;
  99. return Math.round(props.paddingTop + (drawHeight.value - ratio * drawHeight.value));
  100. }
  101. const pointsArr = computed(() => {
  102. const n = nPoints.value;
  103. if (n === 0) return [];
  104. const slice = dataSlice.value;
  105. const w = drawWidth.value;
  106. const dx = n > 1 ? w / (n - 1) : 0;
  107. return slice.map((v, i) => {
  108. const x = Math.round(props.paddingLeft + i * dx);
  109. return [x, project(v)];
  110. });
  111. });
  112. const pointsStr = computed(() => pointsArr.value.map((p) => `${p[0]},${p[1]}`).join(' '));
  113. const areaPath = computed(() => {
  114. if (pointsArr.value.length === 0) return '';
  115. const first = pointsArr.value[0];
  116. const last = pointsArr.value[pointsArr.value.length - 1];
  117. const baseY = props.paddingTop + drawHeight.value;
  118. const line = pointsStr.value.replace(/ /g, ' L ');
  119. return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
  120. });
  121. const gridLines = computed(() => {
  122. if (!props.showGrid) return [];
  123. const h = drawHeight.value;
  124. const w = drawWidth.value;
  125. return [0, 0.25, 0.5, 0.75, 1].map((r) => {
  126. const y = Math.round(props.paddingTop + h * r);
  127. return { x1: props.paddingLeft, y1: y, x2: props.paddingLeft + w, y2: y };
  128. });
  129. });
  130. const lastPoint = computed(() => {
  131. if (pointsArr.value.length === 0) return null;
  132. return pointsArr.value[pointsArr.value.length - 1];
  133. });
  134. // Y-axis tick rendering. We pick a small number of evenly spaced values
  135. // inside the resolved domain and run them through yFormatter — that's
  136. // what makes "MB/s" / "clients" / "%" all render correctly without the
  137. // caller having to subclass the component.
  138. const yTicks = computed(() => {
  139. if (!props.showAxes) return [];
  140. const { min, max } = yDomain.value;
  141. const out = [];
  142. // For percent-style domains keep the legacy fixed step; otherwise
  143. // default to 4 evenly spaced ticks (5 lines including the bottom).
  144. if (props.valueMax === 100 && props.valueMin === 0 && props.yTickStep > 0) {
  145. for (let p = min; p <= max; p += props.yTickStep) {
  146. const y = project(p);
  147. out.push({ y, label: props.yFormatter(p) });
  148. }
  149. return out;
  150. }
  151. const ticks = 5;
  152. for (let i = 0; i < ticks; i++) {
  153. const v = min + ((max - min) * i) / (ticks - 1);
  154. out.push({ y: project(v), label: props.yFormatter(v) });
  155. }
  156. return out;
  157. });
  158. const xTicks = computed(() => {
  159. if (!props.showAxes) return [];
  160. const labels = labelsSlice.value;
  161. const n = nPoints.value;
  162. if (n === 0) return [];
  163. const m = Math.max(2, props.tickCountX);
  164. const w = drawWidth.value;
  165. const dx = n > 1 ? w / (n - 1) : 0;
  166. const out = [];
  167. for (let i = 0; i < m; i++) {
  168. const idx = Math.round((i * (n - 1)) / (m - 1));
  169. const label = labels[idx] != null ? String(labels[idx]) : String(idx);
  170. const x = Math.round(props.paddingLeft + idx * dx);
  171. out.push({ x, label });
  172. }
  173. return out;
  174. });
  175. function onMouseMove(evt) {
  176. if (!props.showTooltip || pointsArr.value.length === 0) return;
  177. const rect = evt.currentTarget.getBoundingClientRect();
  178. const px = evt.clientX - rect.left;
  179. const x = (px / rect.width) * effectiveVbWidth.value;
  180. const n = nPoints.value;
  181. const dx = n > 1 ? drawWidth.value / (n - 1) : 0;
  182. const idx = Math.max(0, Math.min(n - 1, Math.round((x - props.paddingLeft) / (dx || 1))));
  183. hoverIdx.value = idx;
  184. }
  185. function onMouseLeave() {
  186. hoverIdx.value = -1;
  187. }
  188. function fmtHoverText() {
  189. const idx = hoverIdx.value;
  190. if (idx < 0 || idx >= dataSlice.value.length) return '';
  191. const raw = Number(dataSlice.value[idx] || 0);
  192. const fmt = props.tooltipFormatter || props.yFormatter;
  193. const val = fmt(Number.isFinite(raw) ? raw : 0);
  194. const lab = labelsSlice.value[idx] != null ? labelsSlice.value[idx] : '';
  195. return `${val}${lab ? ' • ' + lab : ''}`;
  196. }
  197. // Stable per-instance gradient id so multiple sparklines on a page
  198. // don't clobber each other's <defs id="spkGrad">.
  199. const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
  200. </script>
  201. <template>
  202. <svg ref="svgRef" width="100%" :height="height" :viewBox="viewBoxAttr" preserveAspectRatio="none"
  203. class="sparkline-svg" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
  204. <defs>
  205. <linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
  206. <stop offset="0%" :stop-color="stroke" :stop-opacity="fillOpacity" />
  207. <stop offset="100%" :stop-color="stroke" stop-opacity="0" />
  208. </linearGradient>
  209. </defs>
  210. <g v-if="showGrid">
  211. <line v-for="(g, i) in gridLines" :key="i" :x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2" :stroke="gridColor"
  212. stroke-width="1" class="cpu-grid-line" />
  213. </g>
  214. <g v-if="showAxes">
  215. <text v-for="(t, i) in yTicks" :key="'y' + i" class="cpu-grid-y-text" :x="Math.max(0, paddingLeft - 4)"
  216. :y="t.y + 4" text-anchor="end" font-size="10">{{ t.label }}</text>
  217. <text v-for="(t, i) in xTicks" :key="'x' + i" class="cpu-grid-x-text" :x="t.x" :y="paddingTop + drawHeight + 14"
  218. text-anchor="middle" font-size="10">{{ t.label }}</text>
  219. </g>
  220. <path v-if="areaPath" :d="areaPath" :fill="`url(#${gradId})`" stroke="none" />
  221. <polyline :points="pointsStr" fill="none" :stroke="stroke" :stroke-width="strokeWidth" stroke-linecap="round"
  222. stroke-linejoin="round" />
  223. <circle v-if="showMarker && lastPoint" :cx="lastPoint[0]" :cy="lastPoint[1]" :r="markerRadius" :fill="stroke" />
  224. <g v-if="showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx]">
  225. <line class="cpu-grid-h-line" :x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]" :y1="paddingTop"
  226. :y2="paddingTop + drawHeight" stroke="rgba(0,0,0,0.2)" stroke-width="1" />
  227. <circle :cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]" r="3.5" :fill="stroke" />
  228. <text class="cpu-grid-text" :x="pointsArr[hoverIdx][0]" :y="paddingTop + 12" text-anchor="middle"
  229. font-size="11">{{ fmtHoverText() }}</text>
  230. </g>
  231. </svg>
  232. </template>
  233. <style scoped>
  234. .sparkline-svg {
  235. display: block;
  236. width: 100%;
  237. }
  238. </style>
  239. <!-- Axis labels live on SVG <text> elements; Vue's scoped CSS doesn't
  240. reliably hash-attribute SVG descendants, so the dark-mode overrides
  241. have to live in a non-scoped block to actually take effect. The
  242. numbers are also small, so the dark-theme fills run at ~85% opacity
  243. for legibility (the previous 55% was washed out on navy backgrounds). -->
  244. <style>
  245. .sparkline-svg .cpu-grid-y-text,
  246. .sparkline-svg .cpu-grid-x-text {
  247. fill: rgba(0, 0, 0, 0.65);
  248. }
  249. .sparkline-svg .cpu-grid-text {
  250. fill: rgba(0, 0, 0, 0.88);
  251. }
  252. body.dark .sparkline-svg .cpu-grid-y-text,
  253. body.dark .sparkline-svg .cpu-grid-x-text {
  254. fill: rgba(255, 255, 255, 0.85);
  255. }
  256. body.dark .sparkline-svg .cpu-grid-text {
  257. fill: rgba(255, 255, 255, 0.95);
  258. }
  259. body.dark .sparkline-svg .cpu-grid-line {
  260. stroke: rgba(255, 255, 255, 0.12);
  261. }
  262. body.dark .sparkline-svg .cpu-grid-h-line {
  263. stroke: rgba(255, 255, 255, 0.35);
  264. }
  265. </style>