1 #! /usr/bin/env bash 2 3 function timeSeriesToLineSVG() { 4 local series 5 local seriesIdx color label 6 local awkVars awkFuncs 7 local lowTime highTime lowValue highValue 8 local seriesLowTime seriesHighTime seriesLowValue seriesHighValue 9 local graphWidth graphHeight offsetLeft offsetRight offsetTop offsetBottom 10 local graphLabelX graphLabelY 11 local imageWidth imageHeight 12 local svgStyleSheet 13 local graphColors 14 local ticksX ticksY defaultTicksX defaultTicksY minTicksX minTicksY 15 16 # Determine graph parameters 17 graphWidth=500 18 graphHeight=150 19 graphLabelX='Time' 20 graphLabelY='Value' 21 offsetLeft=150 22 offsetRight=20 23 offsetTop=10 24 offsetBottom=40 25 defaultTicksX=10 26 defaultTicksY=10 27 minTicksX=4 28 minTicksY=2 29 graphColors=( 30 '#ffa500' 31 '#9acd32' 32 '#f1ca3a' 33 '#e41a1c' 34 '#1c91c0' 35 '#43459d' 36 '#984ea3' 37 '#ff7f00' 38 '#ffff33' 39 '#a65628' 40 '#f781bf' 41 ) 42 43 # Get graph options 44 while true; do 45 case "$1" in 46 -width) 47 shift 48 graphWidth="$1" 49 shift 50 ;; 51 -height) 52 shift 53 graphHeight="$1" 54 shift 55 ;; 56 -labelx) 57 shift 58 graphLabelX="$1" 59 shift 60 ;; 61 -labely) 62 shift 63 graphLabelY="$1" 64 shift 65 ;; 66 -style) 67 shift 68 svgStyleSheet="$1" 69 shift 70 ;; 71 -colors) 72 shift 73 graphColors=($1) 74 shift 75 ;; 76 -ticksx) 77 shift 78 ticksX="$1" 79 shift 80 ;; 81 -ticksy) 82 ticksY="$1" 83 ;; 84 *) 85 break 86 ;; 87 esac 88 done 89 90 # Helper functions for awk 91 awkFuncs=' 92 function relativeToAbsoluteX(relPositionX) { 93 relPositionX = int(graphWidth * relPositionX + 0.5); 94 relPositionX += offsetLeft; 95 return(relPositionX); 96 } 97 98 function relativeToAbsoluteY(relPositionY) { 99 relPositionY = int(graphHeight * relPositionY + 0.5); 100 relPositionY = (relPositionY - graphHeight) * -1; 101 relPositionY += offsetTop; 102 return(relPositionY); 103 } 104 ' 105 106 # Determine ranges for all series 107 for series in "$@"; do 108 series="$(echo "${series}" | cut -f 2 -d =)" 109 110 read -r seriesLowTime seriesHighTime seriesLowValue seriesHighValue seriesElements < <(echo "${series}" | tr ' ' $'\n' | awk -F , "${awkVars[@]}" "${awkFuncs}"' 111 BEGIN{ 112 dataIndex = 0; 113 } 114 115 { 116 time = $1; 117 value = $2; 118 119 if (dataIndex == 0) { 120 lowValue = value; 121 highValue = value; 122 lowTime = time; 123 highTime = time; 124 } 125 126 if (value < lowValue) { 127 lowValue = value; 128 } 129 130 if (value > highValue) { 131 highValue = value; 132 133 } 134 135 if (time < lowTime) { 136 lowTime = time; 137 } 138 if (time > highTime) { 139 highTime = time; 140 } 141 142 dataIndex++; 143 } 144 145 END{ 146 print lowTime, highTime, lowValue, highValue, dataIndex 147 } 148 ') 149 150 if [ -z "${lowTime}" ]; then 151 lowTime="${seriesLowTime}" 152 highTime="${seriesHighTime}" 153 lowValue="${seriesLowValue}" 154 highValue="${seriesHighValue}" 155 highSeriesElements="${seriesElements}" 156 else 157 read -r lowTime highTime lowValue highValue highSeriesElements < <(awk \ 158 -v highTime="${highTime}" -v lowTime="${lowTime}" -v lowValue="${lowValue}" -v highValue="${highValue}" \ 159 -v seriesHighTime="${seriesHighTime}" -v seriesLowTime="${seriesLowTime}" -v seriesLowValue="${seriesLowValue}" -v seriesHighValue="${seriesHighValue}" \ 160 -v seriesElements="${seriesElements}" -v highSeriesElements="${highSeriesElements}" ' 161 END{ 162 if (seriesLowTime < lowTime) { 163 lowTime = seriesLowTime; 164 } 165 166 if (seriesHighTime > highTime) { 167 highTime = seriesHighTime; 168 } 169 170 if (seriesLowValue < lowValue) { 171 lowValue = seriesLowValue; 172 } 173 174 if (seriesHighValue > highValue) { 175 highValue = seriesHighValue; 176 } 177 178 if (seriesElements > highSeriesElements) { 179 highSeriesElements = seriesElements; 180 } 181 182 print lowTime, highTime, lowValue, highValue, highSeriesElements; 183 } 184 ' </dev/null) 185 186 fi 187 done 188 189 if [ "${highTime}" = "${lowTime}" ]; then 190 highTime=$[$highTime + 1] 191 lowTime=$[$lowTime - 1] 192 fi 193 194 if [ "${highValue}" = "${lowValue}" ]; then 195 highValue=$[$highValue + 1] 196 fi 197 198 if [ -z "${ticksX}" ]; then 199 highSeriesElements=$[$highSeriesElements - 1] 200 201 ticksX="${defaultTicksX}" 202 203 if [ "${highSeriesElements}" -lt "${defaultTicksX}" ]; then 204 ticksX="${highSeriesElements}" 205 fi 206 207 if [ "${ticksX}" -lt "${minTicksX}" ]; then 208 ticksX="${minTicksX}" 209 fi 210 fi 211 212 if [ -z "${ticksY}" ]; then 213 ticksY="${defaultTicksY}" 214 fi 215 216 # Adjust the value ranges so the chart doesn't just ride at the top and bottom 217 read -r highValue lowValue < <(awk -v highValue="${highValue}" -v lowValue="${lowValue}" ' 218 END{ 219 origLowValue = lowValue; 220 221 adjustValue = (highValue - lowValue) * 0.05; 222 if (adjustValue > 1) { 223 adjustValue = int(adjustValue) 224 } 225 226 if (adjustValue < 0.00001) { 227 adjustValue = 1; 228 } 229 230 highValue += adjustValue; 231 lowValue -= adjustValue; 232 233 if (lowValue < 0) { 234 if (origLowValue >= 0) { 235 lowValue = 0; 236 } 237 } 238 239 print highValue, lowValue; 240 } 241 ' </dev/null) 242 243 # Emit SVG 244 imageWidth=$[${offsetLeft} + ${offsetRight} + ${graphWidth}] 245 imageHeight=$[${offsetTop} + ${offsetBottom} + ${graphHeight}] 246 awkVars=( 247 -v lowTime="${lowTime}" -v highTime="${highTime}" -v lowValue="${lowValue}" -v highValue="${highValue}" 248 -v offsetLeft="${offsetLeft}" -v offsetRight="${offsetRight}" 249 -v offsetTop="${offsetTop}" -v offsetBottom="${offsetBottom}" 250 -v graphWidth="${graphWidth}" -v graphHeight="${graphHeight}" 251 -v graphLabelX="${graphLabelX}" -v graphLabelY="${graphLabelY}" 252 -v ticksX="${ticksX}" -v ticksY="${ticksY}" 253 ) 254 echo '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '"${imageWidth}" "${imageHeight}"'" height="'"${imageHeight}"'px" width="'"${imageWidth}"'px">' 255 if [ -n "${svgStyleSheet}" ]; then 256 echo "<style><![CDATA[${svgStyleSheet}]]></style>" 257 fi 258 259 ## Emit the data for each series 260 seriesIdx='-1' 261 for series in "$@"; do 262 ((seriesIdx++)) || : 263 color="${graphColors[${seriesIdx}]}" 264 label="$(echo "${series}" | cut -f 1 -d =)" 265 series="$(echo "${series}" | cut -f 2 -d = | tr ' ' $'\n' | sort -t , -k 1n,1n)" 266 267 ### Draw the legend 268 awk "${awkVars[@]}" -v label="${label}" -v color="${color}" -v seriesIdx="${seriesIdx}" "${awkFuncs}"' 269 END{ 270 print "<polyline fill=\"" color "\" stroke=\"#dedede\" stroke-width=\"1\" points=\"" 271 272 logoSize = 10; 273 spacingSize = 5; 274 275 /* Border */ 276 legendMarkerX = logoSize; 277 legendMarkerY = offsetTop + logoSize; 278 279 legendMarkerY += seriesIdx * 2 * logoSize 280 281 print legendMarkerX "," legendMarkerY 282 print legendMarkerX "," legendMarkerY + logoSize 283 print legendMarkerX + logoSize "," legendMarkerY + logoSize 284 print legendMarkerX + logoSize "," legendMarkerY 285 print legendMarkerX "," legendMarkerY 286 287 print "\"/>" 288 print "<text class=\"labelText\" x=\"" (legendMarkerX + logoSize + spacingSize) "\" y=\"" legendMarkerY + (logoSize / 2) "\" dominant-baseline=\"central\">" label "</text>" 289 } 290 ' </dev/null 291 292 ### Plot the data 293 #### If there is only one data point in the series, draw a circle at the point 294 seriesElementsTwo="$(echo "${series}" | head -n 2 | wc -l)" 295 if [ "${seriesElementsTwo}" = '1' ]; then 296 echo "${series}" | awk -v color="${color}" -F , "${awkVars[@]}" "${awkFuncs}"' 297 { 298 time = $1 299 positionX = (time - lowTime) / (highTime - lowTime); 300 positionX = relativeToAbsoluteX(positionX); 301 302 value = $2 303 positionY = (value - lowValue) / (highValue - lowValue); 304 positionY = relativeToAbsoluteY(positionY); 305 306 print "<circle cx=\"" positionX "\" cy=\"" positionY "\" r=\"5\" stroke-width=\"3\" stroke=\"" color "\" fill=\"none\"/>" 307 } 308 ' 309 continue 310 fi 311 312 echo "<polyline stroke-linejoin=\"round\" stroke-linecap=\"round\" fill=\"none\" stroke=\"${color}\" stroke-width=\"3\" points=\"" 313 echo "${series}" | awk -F , "${awkVars[@]}" "${awkFuncs}"' 314 BEGIN{ 315 dataIndex = 0; 316 } 317 318 { 319 time = $1; 320 value = $2; 321 322 seriesTime[dataIndex] = time; 323 seriesValue[dataIndex] = value; 324 325 dataIndex++; 326 } 327 328 END{ 329 for (idx in seriesTime) { 330 time = seriesTime[idx]; 331 positionX = (time - lowTime) / (highTime - lowTime); 332 positionX = relativeToAbsoluteX(positionX); 333 334 value = seriesValue[idx]; 335 positionY = (value - lowValue) / (highValue - lowValue); 336 positionY = relativeToAbsoluteY(positionY); 337 338 print positionX "," positionY; 339 } 340 } 341 ' 342 echo '"/>' 343 done 344 345 ## Emit the grid 346 awk "${awkVars[@]}" "${awkFuncs}"' 347 END{ 348 # Various parameters (may be exposed later) 349 textHeight = 14; 350 spacingSize = 13; 351 tickSizeX = 6; 352 tickSizeY = 6; 353 354 # Axis bars 355 axisHalfWidth = 1; 356 print "<polyline fill=\"none\" stroke=\"#aaaaaa\" stroke-width=\"" axisHalfWidth * 2 "\" points=\"" 357 print relativeToAbsoluteX(0) - axisHalfWidth , relativeToAbsoluteY(1) - axisHalfWidth; 358 print relativeToAbsoluteX(0) - axisHalfWidth, relativeToAbsoluteY(0) + axisHalfWidth; 359 print relativeToAbsoluteX(1) + axisHalfWidth, relativeToAbsoluteY(0) + axisHalfWidth; 360 print "\"/>" 361 362 # Axis labels 363 ## X 364 positionY = relativeToAbsoluteY(0) + axisHalfWidth + textHeight + (tickSizeX / 2); 365 print "<text class=\"axisLabel\" dominant-baseline=\"hanging\" text-anchor=\"middle\" x=\"" relativeToAbsoluteX(0.5) "\" y=\"" positionY "\">" graphLabelX "</text>" 366 367 ## Y 368 positionX = relativeToAbsoluteX(1) + (2 * axisHalfWidth); 369 print "<text class=\"axisLabel\" style=\"writing-mode: tb; glyph-orientation-vertical: 90;\" dominant-baseline=\"text-after-edge\" text-anchor=\"middle\" x=\"" positionX "\" y=\"" relativeToAbsoluteY(0.5) "\">" graphLabelY "</text>" 370 371 # Step ticks 372 ## X 373 lastValueXYear = "" 374 lastValueXMonthDay = "" 375 for (tick = 1; tick <= (ticksX - 1); tick++) { 376 relValueX = (tick / ticksX) 377 valueX = int((highTime - lowTime) * relValueX + lowTime + 0.5); 378 dateCommandYear = "date -d @" valueX " +%Y"; 379 dateCommandMonthDay = "date -d @" valueX " +%m-%d"; 380 dateCommandYear | getline valueXYear 381 dateCommandMonthDay | getline valueXMonthDay 382 close(dateCommandYear); 383 close(dateCommandMonthDay); 384 385 if (valueXYear == lastValueXYear) { 386 if (valueXMonthDay == lastValueXMonthDay && lastValueXPresentation != "year") { 387 dateCommandTime = "date -d @" valueX " +%H:%M"; 388 dateCommandTime | getline valueXTime 389 close(dateCommandTime); 390 391 valueXStr = valueXTime; 392 393 lastValueXPresentation = "hour"; 394 } else { 395 valueXStr = valueXMonthDay; 396 397 lastValueXPresentation = "month"; 398 } 399 } else { 400 valueXStr = valueXYear; 401 402 lastValueXPresentation = "year"; 403 } 404 405 lastValueXYear = valueXYear; 406 lastValueXMonthDay = valueXMonthDay; 407 408 valueX = valueXStr; 409 410 positionX = relativeToAbsoluteX(relValueX); 411 positionY = relativeToAbsoluteY(0) + axisHalfWidth; 412 413 print "<line x1=\"" positionX "\" x2=\"" positionX "\" y1=\"" positionY + (tickSizeX / 2) "\" y2=\"" positionY - (tickSizeX / 2) "\" stroke=\"#000000\"/>"; 414 print "<text class=\"axisTick\" x=\"" positionX "\" y=\"" positionY + (tickSizeX / 2) + axisHalfWidth "\" dominant-baseline=\"hanging\" text-anchor=\"middle\" style=\"font-size: " textHeight "px\">" valueX "</text>" 415 } 416 417 ## Y 418 419 lastValueY = ""; 420 usePreciseValues = 0; 421 if ((highValue - lowValue) < ticksY) { 422 usePreciseValues = 1; 423 } 424 for (tick = 1; tick <= (ticksY - 1); tick++) { 425 relValueY = (tick / ticksY) 426 valueY = int((highValue - lowValue) * relValueY + lowValue + 0.5); 427 428 if (valueY == lastValueY || usePreciseValues == 1) { 429 valueY = (highValue - lowValue) * relValueY + lowValue; 430 431 usePrceiseValues = 1; 432 } 433 lastValueY = valueY; 434 435 positionX = relativeToAbsoluteX(0) - axisHalfWidth; 436 positionY = relativeToAbsoluteY(relValueY); 437 438 print "<line x1=\"" positionX - (tickSizeY / 2) "\" x2=\"" positionX + (tickSizeY / 2) "\" y1=\"" positionY "\" y2=\"" positionY "\" stroke=\"#000000\"/>"; 439 print "<text class=\"axisTick\" x=\"" positionX - (tickSizeY / 2) - (spacingSize / 3) "\" y=\"" positionY "\" dominant-baseline=\"central\" text-anchor=\"end\" style=\"font-size: " textHeight "px\">" valueY "</text>" 440 } 441 442 } 443 ' </dev/null 444 445 echo '</svg>' 446 } 447 448 function svgToPNG() { 449 local svg 450 local width height 451 local pngFile 452 local htmlImage 453 local tmpfileHTML tmpfilePNG 454 455 pngFile="$1" 456 457 svg="$(cat)" 458 if [ -z "${svg}" ]; then 459 return 0 460 fi 461 462 read -r width height < <(echo "${svg}" | sed ' 463 /<svg/{ 464 h 465 s/.*width="// 466 s/".*$// 467 s/px.*$// 468 x 469 s/.*height="// 470 s/".*$// 471 s/px.*$// 472 H 473 x 474 q 475 } 476 ' | tr $'\n' ' '; echo) 477 478 htmlImage="$( 479 echo -n '<img width="'"${width}"'" height="'"${height}"'" src="data:image/svg+xml;base64,' 480 echo "${svg}" | base64 | tr -d $'\n' 481 echo '">' 482 )" 483 484 # Use Google Chrome to render it to a PNG if requested and possible 485 tmpfileHTML="$(mktemp -u).html" 486 ( 487 echo "<html><head><style>body { margin: 0; }</style></head><body>" 488 echo "${htmlImage}" 489 echo "</body></html>" 490 ) > "${tmpfileHTML}" 491 492 tmpfilePNG="${tmpfileHTML}.png" 493 rm -f "${tmpfilePNG}" 494 google-chrome --headless --disable-gpu --screenshot="${tmpfilePNG}" --hide-scrollbars --window-size="${width}x${height}" "file://${tmpfileHTML}" >/dev/null 2>/dev/null </dev/null 495 496 if [ -s "${tmpfilePNG}" ]; then 497 if [ -n "${pngFile}" ]; then 498 mv "${tmpfilePNG}" "${pngFile}" 499 else 500 cat "${tmpfilePNG}" 501 fi 502 else 503 return 1 504 fi 505 506 rm -f "${tmpfileHTML}" "${tmpfilePNG}" 507 508 return 0 509 } 510 511 outputMode="png" 512 if [ "$1" = '-svg' ]; then 513 shift 514 outputMode='svg' 515 fi 516 517 518 timeSeriesToLineSVG "$@" | ( 519 if [ "${outputMode}" = 'svg' ]; then 520 cat 521 else 522 svgToPNG 523 fi 524 ) |