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