1 module ggplotd.ggplotd; 2 3 import cconfig = cairo.c.config; 4 import cpdf = cairo.pdf; 5 import csvg = cairo.svg; 6 import cairo = cairo; 7 8 import ggplotd.aes; 9 import ggplotd.axes; 10 import ggplotd.colour; 11 import ggplotd.geom; 12 import ggplotd.bounds; 13 import ggplotd.scale; 14 import ggplotd.theme; 15 import ggplotd.colourspace : RGBA, toCairoRGBA; 16 17 version (unittest) 18 { 19 import dunit.toolkit; 20 } 21 22 alias TitleFunction = Title delegate(Title); 23 24 // Currently only holds the title. In the future could also be used to store details on location etc. 25 struct Title 26 { 27 /// The actual title 28 string title; 29 } 30 31 /// 32 TitleFunction title( string title ) 33 { 34 return delegate(Title t) { t.title = title; return t; }; 35 } 36 37 private auto createEmptySurface( string fname, int width, int height, 38 RGBA colour ) 39 { 40 cairo.Surface surface; 41 42 static if (cconfig.CAIRO_HAS_PDF_SURFACE) 43 { 44 if (fname[$ - 3 .. $] == "pdf") 45 { 46 surface = new cpdf.PDFSurface(fname, width, height); 47 } 48 } 49 else 50 { 51 if (fname[$ - 3 .. $] == "pdf") 52 assert(0, "PDF support not enabled by cairoD"); 53 } 54 static if (cconfig.CAIRO_HAS_SVG_SURFACE) 55 { 56 if (fname[$ - 3 .. $] == "svg") 57 { 58 surface = new csvg.SVGSurface(fname, width, height); 59 } 60 } 61 else 62 { 63 if (fname[$ - 3 .. $] == "svg") 64 assert(0, "SVG support not enabled by cairoD"); 65 } 66 if (fname[$ - 3 .. $] == "png") 67 { 68 surface = new cairo.ImageSurface(cairo.Format.CAIRO_FORMAT_ARGB32, width, height); 69 } 70 71 auto backcontext = cairo.Context(surface); 72 backcontext.setSourceRGBA(colour.toCairoRGBA); 73 backcontext.paint; 74 75 return surface; 76 } 77 78 /// 79 auto drawTitle( in Title title, ref cairo.Surface surface, 80 in Margins margins, int width, int height ) 81 { 82 auto context = cairo.Context(surface); 83 context.setFontSize(16.0); 84 context.moveTo( width/2, margins.top/2 ); 85 auto extents = context.textExtents(title.title); 86 87 auto textSize = cairo.Point!double(0.5 * extents.width, 0.5 * extents.height); 88 context.relMoveTo(-textSize.x, textSize.y); 89 90 context.showText(title.title); 91 return surface; 92 } 93 94 auto drawGeom( in Geom geom, ref cairo.Surface surface, 95 in ColourMap colourMap, in ScaleType scaleFunction, in Bounds bounds, 96 in Margins margins, int width, int height ) 97 { 98 cairo.Context context; 99 if (geom.mask) { 100 auto plotSurface = cairo.Surface.createForRectangle(surface, 101 cairo.Rectangle!double(margins.left, margins.top, 102 width - (margins.left+margins.right), 103 height - (margins.top+margins.bottom))); 104 context = cairo.Context(plotSurface); 105 } else { 106 context = cairo.Context(surface); 107 context.translate(margins.left, margins.top); 108 } 109 import std.conv : to; 110 context = scaleFunction(context, bounds, 111 width.to!double - (margins.left+margins.right), 112 height.to!double - (margins.top+margins.bottom)); 113 context = geom.draw(context, colourMap); 114 return surface; 115 } 116 117 /// 118 struct Margins 119 { 120 size_t left = 50; /// 121 size_t right = 20; /// 122 size_t bottom = 50; /// 123 size_t top = 40; /// 124 } 125 126 /// 127 struct GGPlotD 128 { 129 Geom[] geomRange; 130 131 XAxis xaxis; 132 YAxis yaxis; 133 134 Margins margins; 135 136 Title title; 137 Theme theme; 138 139 ScaleType scaleFunction; 140 141 ColourGradientFunction colourGradientFunction; 142 143 /// 144 auto drawToSurface( ref cairo.Surface surface, int width, int height ) const 145 { 146 import std.range : empty, front; 147 148 AdaptiveBounds bounds; 149 ColourID[] colourIDs; 150 Tuple!(double, string)[] xAxisTicks; 151 Tuple!(double, string)[] yAxisTicks; 152 153 foreach (geom; geomRange) 154 { 155 bounds.adapt(geom.bounds); 156 colourIDs ~= geom.colours; 157 xAxisTicks ~= geom.xTickLabels; 158 yAxisTicks ~= geom.yTickLabels; 159 } 160 161 import ggplotd.colourspace : HCY; 162 163 ColourMap colourMap; 164 if (initCG) 165 colourMap = createColourMap( colourIDs, 166 colourGradientFunction ); 167 else 168 colourMap = createColourMap( colourIDs, 169 colourGradient!HCY("") ); 170 171 // Axis 172 import std.algorithm : sort, uniq, min, max; 173 import std.range : chain; 174 import std.array : array; 175 import ggplotd.axes; 176 177 // If ticks are provided then we make sure the bounds include them 178 auto sortedTicks = xAxisTicks.sort().uniq.array; 179 if (!sortedTicks.empty) 180 { 181 bounds.min_x = min( bounds.min_x, sortedTicks[0][0] ); 182 bounds.max_x = max( bounds.max_x, sortedTicks[$-1][0] ); 183 } 184 if (initialized(xaxis)) 185 { 186 bounds.min_x = xaxis.min; 187 bounds.max_x = xaxis.max; 188 } 189 190 import std.math : isNaN; 191 auto offset = bounds.min_y; 192 if (!isNaN(xaxis.offset)) 193 offset = xaxis.offset; 194 auto aesX = axisAes("x", bounds.min_x, bounds.max_x, offset, 195 sortedTicks ); 196 197 sortedTicks = yAxisTicks.sort().uniq.array; 198 if (!sortedTicks.empty) 199 { 200 bounds.min_y = min( bounds.min_y, sortedTicks[0][0] ); 201 bounds.max_y = max( bounds.max_y, sortedTicks[$-1][0] ); 202 } 203 if (initialized(yaxis)) 204 { 205 bounds.min_y = yaxis.min; 206 bounds.max_y = yaxis.max; 207 } 208 209 offset = bounds.min_x; 210 if (!isNaN(yaxis.offset)) 211 offset = yaxis.offset; 212 auto aesY = axisAes("y", bounds.min_y, bounds.max_y, offset, 213 sortedTicks ); 214 215 auto gR = chain(geomAxis(aesX, 10.0*bounds.height / height, xaxis.label), geomAxis(aesY, 10.0*bounds.width / width, yaxis.label)); 216 217 // Plot axis and geomRange 218 foreach (geom; chain(geomRange, gR) ) 219 { 220 if (initScale) 221 surface = geom.drawGeom( surface, 222 colourMap, scaleFunction, bounds, 223 margins, width, height ); 224 else 225 surface = geom.drawGeom( surface, 226 colourMap, scale(), bounds, 227 margins, width, height ); 228 } 229 230 // Plot title 231 surface = title.drawTitle( surface, margins, width, height ); 232 return surface; 233 } 234 235 236 /// 237 void save( string fname, int width = 470, int height = 470 ) const 238 { 239 bool pngWrite = false; 240 auto surface = createEmptySurface( fname, width, height, 241 theme.backgroundColour ); 242 243 surface = drawToSurface( surface, width, height ); 244 245 if (fname[$ - 3 .. $] == "png") 246 { 247 pngWrite = true; 248 } 249 250 if (pngWrite) 251 (cast(cairo.ImageSurface)(surface)).writeToPNG(fname); 252 } 253 254 /// Using + to extend the plot for compatibility to ggplot2 in R 255 ref GGPlotD opBinary(string op, T)(T rhs) if (op == "+") 256 { 257 static if (is(ElementType!T==Geom)) 258 { 259 import std.array : array; 260 geomRange ~= rhs.array; 261 } 262 static if (is(T==ScaleType)) 263 { 264 initScale = true; 265 scaleFunction = rhs; 266 } 267 static if (is(T==XAxisFunction)) 268 { 269 xaxis = rhs( xaxis ); 270 } 271 static if (is(T==YAxisFunction)) 272 { 273 yaxis = rhs( yaxis ); 274 } 275 static if (is(T==TitleFunction)) 276 { 277 title = rhs( title ); 278 } 279 static if (is(T==ThemeFunction)) 280 { 281 theme = rhs( theme ); 282 } 283 static if (is(T==Margins)) 284 { 285 margins = rhs; 286 } 287 static if (is(T==ColourGradientFunction)) { 288 initCG = true; 289 colourGradientFunction = rhs; 290 } 291 return this; 292 } 293 294 /// 295 ref GGPlotD put(T)(T rhs) 296 { 297 return this.opBinary!("+", T)(rhs); 298 } 299 300 private: 301 bool initScale = false; 302 bool initCG = false; 303 } 304 305 unittest 306 { 307 auto gg = GGPlotD() 308 .put( yaxisLabel( "My ylabel" ) ) 309 .put( yaxisRange( 0, 2.0 ) ); 310 assertEqual( gg.yaxis.max, 2.0 ); 311 assertEqual( gg.yaxis.label, "My ylabel" ); 312 313 gg = GGPlotD(); 314 gg.put( yaxisLabel( "My ylabel" ) ) 315 .put( yaxisRange( 0, 2.0 ) ); 316 assertEqual( gg.yaxis.max, 2.0 ); 317 assertEqual( gg.yaxis.label, "My ylabel" ); 318 } 319 320 321 /// 322 unittest 323 { 324 auto aes = Aes!(string[], "x", string[], "y", string[], "colour")(["a", 325 "b", "c", "b"], ["x", "y", "y", "x"], ["b", "b", "b", "b"]); 326 auto gg = GGPlotD(); 327 gg + geomLine(aes) + scale(); 328 gg.save( "test6.png"); 329 } 330 331 /// 332 unittest 333 { 334 /// http://blackedder.github.io/ggplotd/images/noise.png 335 import std.array : array; 336 import std.math : sqrt; 337 import std.algorithm : map; 338 import std.range : repeat, iota; 339 import std.random : uniform; 340 // Generate some noisy data with reducing width 341 auto f = (double x) { return x/(1+x); }; 342 auto width = (double x) { return sqrt(0.1/(1+x)); }; 343 auto xs = iota( 0, 10, 0.1 ).array; 344 345 auto ysfit = xs.map!((x) => f(x)); 346 auto ysnoise = xs.map!((x) => f(x) + uniform(-width(x),width(x))).array; 347 348 auto aes = Aes!(typeof(xs), "x", 349 typeof(ysnoise), "y", string[], "colour" )( xs, ysnoise, ("a").repeat(xs.length).array ); 350 auto gg = GGPlotD().put( geomPoint( aes ) ); 351 gg.put( geomLine( Aes!(typeof(xs), "x", 352 typeof(ysfit), "y" )( xs, ysfit ) ) ); 353 354 // 355 auto ys2fit = xs.map!((x) => 1-f(x)); 356 auto ys2noise = xs.map!((x) => 1-f(x) + uniform(-width(x),width(x))).array; 357 358 gg.put( geomLine( Aes!(typeof(xs), "x", typeof(ys2fit), "y" )( xs, 359 ys2fit) ) ) 360 .put( 361 geomPoint( Aes!(typeof(xs), "x", typeof(ys2noise), "y", string[], 362 "colour" )( xs, ys2noise, ("b").repeat(xs.length).array) ) ); 363 364 gg.save( "noise.png" ); 365 } 366 367 /// 368 unittest 369 { 370 /// http://blackedder.github.io/ggplotd/images/hist.png 371 import std.array : array; 372 import std.algorithm : map; 373 import std.range : repeat, iota; 374 import std.random : uniform; 375 auto xs = iota(0,25,1).map!((x) => uniform(0.0,5)+uniform(0.0,5)).array; 376 auto aes = Aes!(typeof(xs), "x")( xs ); 377 auto gg = GGPlotD().put( geomHist( aes ) ); 378 379 auto ys = (0.0).repeat( xs.length ).array; 380 auto aesPs = aes.mergeRange( Aes!(double[], "y", double[], "colour" ) 381 ( ys, ys ) ); 382 gg.put( geomPoint( aesPs ) ); 383 384 gg.save( "hist.png" ); 385 } 386 387 /// 388 unittest 389 { 390 /// http://blackedder.github.io/ggplotd/images/filled_hist.svg 391 import std.array : array; 392 import std.algorithm : map; 393 import std.range : repeat, iota, chain; 394 import std.random : uniform; 395 auto xs = iota(0,50,1).map!((x) => uniform(0.0,5)+uniform(0.0,5)).array; 396 auto cols = "a".repeat(25).chain("b".repeat(25)); 397 auto aes = Aes!(typeof(xs), "x", typeof(cols), "colour", 398 double[], "fill" )( 399 xs, cols, 0.45.repeat(xs.length).array); 400 auto gg = GGPlotD().put( geomHist( aes ) ); 401 gg.save( "filled_hist.svg" ); 402 } 403 404 /// Boxplot example 405 unittest 406 { 407 /// http://blackedder.github.io/ggplotd/images/boxplot.svg 408 import std.array : array; 409 import std.algorithm : map; 410 import std.range : repeat, iota, chain; 411 import std.random : uniform; 412 auto xs = iota(0,50,1).map!((x) => uniform(0.0,5)+uniform(0.0,5)).array; 413 auto cols = "a".repeat(25).chain("b".repeat(25)).array; 414 auto aes = Aes!(typeof(xs), "x", typeof(cols), "colour", 415 double[], "fill", typeof(cols), "label" )( 416 xs, cols, 0.45.repeat(xs.length).array, cols); 417 auto gg = GGPlotD().put( geomBox( aes ) ); 418 gg.save( "boxplot.svg" ); 419 } 420 421 /// 422 unittest 423 { 424 /// http://blackedder.github.io/ggplotd/images/hist3D.svg 425 import std.array : array; 426 import std.algorithm : map; 427 import std.range : repeat, iota; 428 import std.random : uniform; 429 430 import ggplotd.aes : Aes; 431 import ggplotd.colour : colourGradient; 432 import ggplotd.colourspace : XYZ; 433 import ggplotd.geom : geomHist3D; 434 435 auto xs = iota(0,500,1).map!((x) => uniform(0.0,5)+uniform(0.0,5)).array; 436 auto ys = iota(0,500,1).map!((y) => uniform(0.0,5)+uniform(0.0,5)).array; 437 auto aes = Aes!(typeof(xs), "x", typeof(ys), "y")( xs, ys); 438 auto gg = GGPlotD().put( geomHist3D( aes ) ); 439 // Use a different colour scheme 440 gg.put( colourGradient!XYZ( "white-cornflowerBlue-crimson" ) ); 441 442 gg.save( "hist3D.svg" ); 443 } 444 445 /// Changing axes details 446 unittest 447 { 448 /// http://blackedder.github.io/ggplotd/images/axes.svg 449 import std.array : array; 450 import std.math : sqrt; 451 import std.algorithm : map; 452 import std.range : iota; 453 // Generate some noisy data with reducing width 454 auto f = (double x) { return x/(1+x); }; 455 auto width = (double x) { return sqrt(0.1/(1+x)); }; 456 auto xs = iota( 0, 10, 0.1 ).array; 457 458 auto ysfit = xs.map!((x) => f(x)).array; 459 460 auto gg = GGPlotD().put( geomLine( Aes!(typeof(xs), "x", 461 typeof(ysfit), "y" )( xs, ysfit ) ) ); 462 463 // Setting range and label for xaxis 464 gg.put( xaxisRange( 0, 8 ) ).put( xaxisLabel( "My xlabel" ) ); 465 assertEqual( gg.xaxis.min, 0 ); 466 // Setting range and label for yaxis 467 gg.put( yaxisRange( 0, 2.0 ) ).put( yaxisLabel( "My ylabel" ) ); 468 assertEqual( gg.yaxis.max, 2.0 ); 469 assertEqual( gg.yaxis.label, "My ylabel" ); 470 471 // change offset 472 gg.put( xaxisOffset( 0.25 ) ).put( yaxisOffset( 0.5 ) ); 473 474 // Change Margins 475 gg.put( Margins( 60, 60, 40, 30 ) ); 476 477 // Set a title 478 gg.put( title( "And now for something completely different" ) ); 479 assertEqual( gg.title.title, "And now for something completely different" ); 480 481 // Saving on a 500x300 pixel surface 482 gg.save( "axes.svg", 500, 300 ); 483 } 484 485 /// Polygon 486 unittest 487 { 488 /// http://blackedder.github.io/ggplotd/images/polygon.png 489 auto gg = GGPlotD().put( geomPolygon( 490 Aes!( 491 double[], "x", 492 double[], "y", 493 double[], "colour" )( 494 [1,0,0], [ 1, 1, 0 ], [1,0.1,0] ) ) ); 495 gg.save( "polygon.png" ); 496 } 497 498 /// Setting background colour 499 unittest 500 { 501 /// http://blackedder.github.io/ggplotd/images/background.svg 502 import ggplotd.theme; 503 auto gg = GGPlotD().put( background( RGBA(0.7,0.7,0.7,1) ) ); 504 gg.put( geomPoint( 505 Aes!( 506 double[], "x", 507 double[], "y", 508 double[], "colour" )( 509 [1,0,0], [ 1, 1, 0 ], [1,0.1,0] ) ) ); 510 gg.save( "background.svg" ); 511 } 512 513 /// Other data type 514 unittest 515 { 516 /// http://blackedder.github.io/ggplotd/images/data.png 517 import std.array : array; 518 import std.math : sqrt; 519 import std.algorithm : map; 520 import std.range : repeat, iota; 521 import std.random : uniform; 522 struct Point { double x; double y; } 523 // Generate some noisy data with reducing width 524 auto f = (double x) { return x/(1+x); }; 525 auto width = (double x) { return sqrt(0.1/(1+x)); }; 526 auto xs = iota( 0, 10, 0.1 ).array; 527 528 auto points = xs.map!((x) => Point(x, 529 f(x) + uniform(-width(x),width(x)))); 530 531 auto gg = GGPlotD().put( geomPoint( points ) ); 532 533 gg.save( "data.png" ); 534 } 535 536 537 /// 538 struct Facets 539 { 540 /// 541 void put(GGPlotD facet) 542 { 543 ggs.put( facet ); 544 } 545 546 /// 547 auto drawToSurface( ref cairo.Surface surface, int dimX, int dimY, 548 int width, int height ) const 549 { 550 import std.conv : to; 551 import std.math : floor; 552 import std.range : save; 553 import cairo.cairo : Rectangle; 554 int w = floor( width.to!double/dimX ).to!int; 555 int h = floor( height.to!double/dimY ).to!int; 556 557 auto gs = ggs.data.save; 558 foreach( i; 0..dimX ) 559 { 560 foreach( j; 0..dimY ) 561 { 562 if (!gs.empty) 563 { 564 auto rect = Rectangle!double( w*i, h*j, w, h ); 565 auto subS = cairo.Surface.createForRectangle( surface, rect ); 566 gs.front.drawToSurface( subS, w, h ), 567 gs.popFront; 568 } 569 } 570 } 571 572 return surface; 573 } 574 575 /// 576 auto drawToSurface( ref cairo.Surface surface, 577 int width, int height ) const 578 { 579 import std.conv : to; 580 // Calculate dimX/dimY from width/height 581 auto grid = gridLayout( ggs.data.length, width.to!double/height ); 582 return drawToSurface( surface, grid[0], grid[1], width, height ); 583 } 584 585 586 /// 587 void save( string fname, int dimX, int dimY, int width = 470, int height = 470 ) const 588 { 589 bool pngWrite = false; 590 auto surface = createEmptySurface( fname, width, height, 591 RGBA(1,1,1,1) ); 592 593 surface = drawToSurface( surface, dimX, dimY, width, height ); 594 595 if (fname[$ - 3 .. $] == "png") 596 { 597 pngWrite = true; 598 } 599 600 if (pngWrite) 601 (cast(cairo.ImageSurface)(surface)).writeToPNG(fname); 602 } 603 604 /// 605 void save( string fname, int width = 470, int height = 470 ) const 606 { 607 import std.conv : to; 608 // Calculate dimX/dimY from width/height 609 auto grid = gridLayout( ggs.data.length, width.to!double/height ); 610 save( fname, grid[0], grid[1], width, height ); 611 } 612 613 import std.range : Appender; 614 615 Appender!(GGPlotD[]) ggs; 616 } 617 618 auto gridLayout( size_t length, double ratio ) 619 { 620 import std.conv : to; 621 import std.math : ceil, sqrt; 622 auto h = ceil( sqrt(length/ratio) ); 623 auto w = ceil(length/h); 624 return Tuple!(int, int)( w.to!int, h.to!int ); 625 } 626 627 unittest 628 { 629 assertEqual(gridLayout(4, 1), Tuple!(int, int)(2, 2)); 630 assertEqual(gridLayout(2, 1), Tuple!(int, int)(1, 2)); 631 assertEqual(gridLayout(3, 1), Tuple!(int, int)(2, 2)); 632 assertEqual(gridLayout(2, 2), Tuple!(int, int)(2, 1)); 633 }