TheGraph.vue 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. <script setup lang="ts">
  2. // I was trying to make this a generic graph component that could be reused in the docs
  3. // but it got a bit out of hand. Still, it might be useful for future stuff
  4. // so I'm keeping it here for now.
  5. import { useElementSize } from '@vueuse/core'
  6. interface DataPoint {
  7. x: number
  8. y: number
  9. }
  10. interface Dataset {
  11. label: string
  12. points: DataPoint[]
  13. color: string
  14. strokeWidth?: number
  15. strokeDasharray?: string | number
  16. strokeDashoffset?: string | number
  17. }
  18. interface UIConfig {
  19. strokeWidth?: number
  20. fillColor?: string
  21. backgroundColor?: string
  22. gridColor?: string
  23. showGrid?: boolean
  24. showAxes?: [boolean, boolean] // [x-axis, y-axis]
  25. showLabels?: [boolean, boolean] // [x-labels, y-labels]
  26. axisColor?: string
  27. labelColor?: string
  28. fontSize?: number
  29. padding?: number
  30. dataPadding?: [number, number] // [x-padding %, y-padding %] - extends data bounds
  31. labelIntervals?: [number, number] // [x-interval, y-interval] - step size for labels
  32. }
  33. const props = withDefaults(defineProps<{
  34. data: Dataset[]
  35. ui?: UIConfig
  36. }>(), {
  37. ui: () => ({}),
  38. })
  39. const defaultUI: Required<UIConfig> = {
  40. strokeWidth: 1.5,
  41. fillColor: 'transparent',
  42. backgroundColor: 'transparent',
  43. gridColor: '#e5e7eb',
  44. showGrid: false,
  45. showAxes: [false, false], // [x-axis, y-axis]
  46. showLabels: [false, false], // [x-labels, y-labels]
  47. axisColor: '#6b7280',
  48. labelColor: '#374151',
  49. fontSize: 12,
  50. padding: 40,
  51. dataPadding: [5, 10], // [x-padding %, y-padding %] - 5% x, 10% y
  52. labelIntervals: [1, 0.5], // [x-interval, y-interval] - auto-calculated if not set
  53. }
  54. const config = computed(() => ({ ...defaultUI, ...props.ui }))
  55. const containerRef = ref<HTMLElement>()
  56. const { width, height } = useElementSize(containerRef)
  57. const viewBox = computed(() => `0 0 ${width.value} ${height.value}`)
  58. const bounds = computed(() => {
  59. if (props.data.length === 0) {
  60. return { minX: 0, maxX: 1, minY: 0, maxY: 1 }
  61. }
  62. const allPoints = props.data.flatMap(dataset => dataset.points)
  63. const xs = allPoints.map(d => d.x)
  64. const ys = allPoints.map(d => d.y)
  65. const rawMinX = Math.min(...xs)
  66. const rawMaxX = Math.max(...xs)
  67. const rawMinY = Math.min(...ys)
  68. const rawMaxY = Math.max(...ys)
  69. // Apply data padding (percentage of range)
  70. const [xPaddingPercent, yPaddingPercent] = config.value.dataPadding
  71. const xRange = rawMaxX - rawMinX
  72. const yRange = rawMaxY - rawMinY
  73. const xPadding = (xRange * xPaddingPercent) / 100
  74. const yPadding = (yRange * yPaddingPercent) / 100
  75. return {
  76. minX: rawMinX - xPadding,
  77. maxX: rawMaxX + xPadding,
  78. minY: rawMinY - yPadding,
  79. maxY: rawMaxY + yPadding,
  80. }
  81. })
  82. const scale = computed(() => {
  83. const { minX, maxX, minY, maxY } = bounds.value
  84. const { padding } = config.value
  85. const dataWidth = maxX - minX
  86. const dataHeight = maxY - minY
  87. return {
  88. x: (width.value - 2 * padding) / (dataWidth || 1),
  89. y: (height.value - 2 * padding) / (dataHeight || 1),
  90. }
  91. })
  92. const transformPoint = (point: DataPoint) => {
  93. const { minX, minY } = bounds.value
  94. const { padding } = config.value
  95. return {
  96. x: padding + (point.x - minX) * scale.value.x,
  97. y: height.value - padding - (point.y - minY) * scale.value.y,
  98. }
  99. }
  100. const createPathData = (points: DataPoint[]) => {
  101. if (points.length === 0) {
  102. return ''
  103. }
  104. const transformedPoints = points.map(transformPoint)
  105. return transformedPoints.reduce((path, point, index) => {
  106. const command = index === 0 ? 'M' : 'L'
  107. return `${path} ${command} ${point.x} ${point.y}`
  108. }, '').trim()
  109. }
  110. const gridLines = computed(() => {
  111. const { padding } = config.value
  112. const lines = []
  113. // Vertical grid lines
  114. for (let i = 1; i < 10; i++) {
  115. const x = padding + (width.value - 2 * padding) * (i / 10)
  116. lines.push({ x1: x, y1: padding, x2: x, y2: height.value - padding })
  117. }
  118. // Horizontal grid lines
  119. for (let i = 1; i < 5; i++) {
  120. const y = padding + (height.value - 2 * padding) * (i / 5)
  121. lines.push({ x1: padding, y1: y, x2: width.value - padding, y2: y })
  122. }
  123. return lines
  124. })
  125. const axisLabels = computed(() => {
  126. const { minX, maxX, minY, maxY } = bounds.value
  127. const { padding, fontSize, labelIntervals } = config.value
  128. const [xInterval, yInterval] = labelIntervals
  129. const labels = []
  130. // Y-axis labels (left side) - use interval
  131. if (yInterval > 0) {
  132. const startY = Math.ceil(minY / yInterval) * yInterval
  133. const endY = Math.floor(maxY / yInterval) * yInterval
  134. for (let value = startY; value <= endY; value += yInterval) {
  135. // Handle floating point precision
  136. const roundedValue = Math.round(value / yInterval) * yInterval
  137. const y = height.value - padding - (roundedValue - minY) * scale.value.y
  138. labels.push({
  139. type: 'y',
  140. x: padding - 10,
  141. y: y + fontSize / 3,
  142. text: roundedValue.toFixed(yInterval < 1 ? 1 : 0),
  143. })
  144. }
  145. }
  146. // X-axis labels (bottom) - use interval
  147. if (xInterval > 0) {
  148. const startX = Math.ceil(minX / xInterval) * xInterval
  149. const endX = Math.floor(maxX / xInterval) * xInterval
  150. for (let value = startX; value <= endX; value += xInterval) {
  151. // Handle floating point precision
  152. const roundedValue = Math.round(value / xInterval) * xInterval
  153. const x = padding + (roundedValue - minX) * scale.value.x
  154. labels.push({
  155. type: 'x',
  156. x,
  157. y: height.value - padding + fontSize + 5,
  158. text: roundedValue.toFixed(xInterval < 1 ? 1 : 0),
  159. })
  160. }
  161. }
  162. return labels
  163. })
  164. </script>
  165. <template>
  166. <div ref="containerRef" class="graph-container">
  167. <svg
  168. :viewBox="viewBox"
  169. :width="width"
  170. :height="height"
  171. class="svg-graph"
  172. :style="{ backgroundColor: config.backgroundColor }"
  173. >
  174. <!-- Background -->
  175. <rect
  176. :width="width"
  177. :height="height"
  178. :fill="config.backgroundColor"
  179. />
  180. <!-- Grid -->
  181. <g v-if="config.showGrid" class="grid">
  182. <line
  183. v-for="(line, index) in gridLines"
  184. :key="`grid-${index}`"
  185. :x1="line.x1"
  186. :y1="line.y1"
  187. :x2="line.x2"
  188. :y2="line.y2"
  189. :stroke="config.gridColor"
  190. stroke-width="1"
  191. opacity="0.3"
  192. />
  193. </g>
  194. <!-- Axes -->
  195. <g class="axes">
  196. <!-- X axis (y=0 line) -->
  197. <line
  198. v-if="config.showAxes[0]"
  199. :x1="config.padding"
  200. :y1="transformPoint({ x: 0, y: 0 }).y"
  201. :x2="width - config.padding"
  202. :y2="transformPoint({ x: 0, y: 0 }).y"
  203. :stroke="config.axisColor"
  204. stroke-width="2"
  205. />
  206. <!-- Y axis (x=0 line) -->
  207. <line
  208. v-if="config.showAxes[1]"
  209. :x1="transformPoint({ x: 0, y: 0 }).x"
  210. :y1="config.padding"
  211. :x2="transformPoint({ x: 0, y: 0 }).x"
  212. :y2="height - config.padding"
  213. :stroke="config.axisColor"
  214. stroke-width="2"
  215. />
  216. </g>
  217. <!-- Axis labels -->
  218. <g class="labels">
  219. <text
  220. v-for="(label, index) in axisLabels"
  221. v-show="(label.type === 'x' && config.showLabels[0]) || (label.type === 'y' && config.showLabels[1])"
  222. :key="`label-${index}`"
  223. :x="label.x"
  224. :y="label.y"
  225. :fill="config.labelColor"
  226. :font-size="config.fontSize"
  227. :text-anchor="label.type === 'x' ? 'middle' : 'end'"
  228. font-family="system-ui, sans-serif"
  229. >
  230. {{ label.text }}
  231. </text>
  232. </g>
  233. <!-- Data paths and points for each dataset -->
  234. <g v-for="(dataset, datasetIndex) in data" :key="`dataset-${datasetIndex}`">
  235. <!-- Data path -->
  236. <path
  237. v-if="createPathData(dataset.points)"
  238. :d="createPathData(dataset.points)"
  239. :stroke="dataset.color"
  240. :stroke-width="dataset.strokeWidth ?? config.strokeWidth"
  241. v-bind="{
  242. ...(dataset.strokeDasharray ? { 'stroke-dasharray': dataset.strokeDasharray } : {}),
  243. ...(dataset.strokeDashoffset ? { 'stroke-dashoffset': dataset.strokeDashoffset } : {}),
  244. }"
  245. :fill="config.fillColor"
  246. vector-effect="non-scaling-stroke"
  247. />
  248. <!-- Data points -->
  249. <!-- <g class="data-points">
  250. <circle
  251. v-for="(point, index) in dataset.points"
  252. :key="`point-${datasetIndex}-${index}`"
  253. :cx="transformPoint(point).x"
  254. :cy="transformPoint(point).y"
  255. :r="(dataset.strokeWidth ?? config.strokeWidth) + 1"
  256. :fill="dataset.color"
  257. />
  258. </g> -->
  259. </g>
  260. </svg>
  261. </div>
  262. </template>
  263. <style scoped>
  264. .graph-container {
  265. width: 100%;
  266. height: 100%;
  267. min-height: 200px;
  268. }
  269. .svg-graph {
  270. width: 100%;
  271. height: 100%;
  272. transition: all 0.3s ease;
  273. }
  274. .grid line {
  275. transition: opacity 0.3s ease;
  276. }
  277. path {
  278. transition: d 0.5s ease;
  279. }
  280. circle {
  281. transition:
  282. cx 0.5s ease,
  283. cy 0.5s ease;
  284. }
  285. </style>