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 }