1 module ggplotd.axes;
2 
3 import std.typecons : Tuple;
4 
5 version (unittest)
6 {
7     import dunit.toolkit;
8 }
9 
10 /++
11 Struct holding details on axis
12 +/
13 struct Axis
14 {
15     /// Creating axis giving a minimum and maximum value
16     this(double newmin, double newmax)
17     {
18         min = newmin;
19         max = newmax;
20         min_tick = min;
21     }
22 
23     /// Label of the axis
24     string label;
25 
26     /// Minimum value of the axis
27     double min;
28     /// Maximum value of the axis
29     double max;
30     /// Location of the lowest tick
31     double min_tick = -1;
32     /// Distance between ticks
33     double tick_width = 0.2;
34 
35     /// Offset of the axis
36     double offset;
37 
38     /// Show the axis or hide it
39     bool show = true;
40 }
41 
42 /// XAxis
43 struct XAxis {
44     /// The general Axis struct
45     Axis axis;
46     alias axis this;
47 }
48 
49 /// YAxis
50 struct YAxis {
51     /// The general Axis struct
52     Axis axis;
53     alias axis this;
54 }
55 
56 /**
57     Is the axis properly initialized? Valid range.
58 */
59 bool initialized( in Axis axis )
60 {
61     import std.math : isNaN;
62     if ( isNaN(axis.min) || isNaN(axis.max) || axis.max <= axis.min )
63         return false;
64     return true;
65 }
66 
67 unittest
68 {
69     auto ax = Axis();
70     assert( !initialized( ax ) );
71     ax.min = -1;
72     assert( !initialized( ax ) );
73     ax.max = -1;
74     assert( !initialized( ax ) );
75     ax.max = 1;
76     assert( initialized( ax ) );
77 }
78 
79 /**
80     Calculate optimal tick width given an axis and an approximate number of ticks
81     */
82 Axis adjustTickWidth(Axis axis, size_t approx_no_ticks)
83 {
84     import std.math : abs, floor, ceil, pow, log10, round;
85     assert( initialized(axis), "Axis range has not been set" );
86 
87     auto axis_width = axis.max - axis.min;
88     auto scale = cast(int) floor(log10(axis_width));
89     auto acceptables = [0.1, 0.2, 0.5, 1.0, 2.0, 5.0]; // Only accept ticks of these sizes
90     auto approx_width = pow(10.0, -scale) * (axis_width) / approx_no_ticks;
91     // Find closest acceptable value
92     double best = acceptables[0];
93     double diff = abs(approx_width - best);
94     foreach (accept; acceptables[1 .. $])
95     {
96         if (abs(approx_width - accept) < diff)
97         {
98             best = accept;
99             diff = abs(approx_width - accept);
100         }
101     }
102 
103     if (round(best/approx_width)>1)
104         best /= round(best/approx_width);
105     if (round(approx_width/best)>1)
106         best *= round(approx_width/best);
107     axis.tick_width = best * pow(10.0, scale);
108     // Find good min_tick
109     axis.min_tick = ceil(axis.min * pow(10.0, -scale)) * pow(10.0, scale);
110     //debug writeln( "Here 120 ", axis.min_tick, " ", axis.min, " ", 
111     //		axis.max,	" ", axis.tick_width, " ", scale );
112     while (axis.min_tick - axis.tick_width > axis.min)
113         axis.min_tick -= axis.tick_width;
114     return axis;
115 }
116 
117 unittest
118 {
119     adjustTickWidth(Axis(0, .4), 5);
120     adjustTickWidth(Axis(0, 4), 8);
121     assert(adjustTickWidth(Axis(0, 4), 5).tick_width == 1.0);
122     assert(adjustTickWidth(Axis(0, 4), 8).tick_width == 0.5);
123     assert(adjustTickWidth(Axis(0, 0.4), 5).tick_width == 0.1);
124     assert(adjustTickWidth(Axis(0, 40), 8).tick_width == 5);
125     assert(adjustTickWidth(Axis(-0.1, 4), 8).tick_width == 0.5);
126     assert(adjustTickWidth(Axis(-0.1, 4), 8).min_tick == 0.0);
127     assert(adjustTickWidth(Axis(0.1, 4), 8).min_tick == 0.5);
128     assert(adjustTickWidth(Axis(1, 40), 8).min_tick == 5);
129     assert(adjustTickWidth(Axis(3, 4), 5).min_tick == 3);
130     assert(adjustTickWidth(Axis(3, 4), 5).tick_width == 0.2);
131     assert(adjustTickWidth(Axis(1.79877e+07, 1.86788e+07), 5).min_tick == 1.8e+07);
132     assert(adjustTickWidth(Axis(1.79877e+07, 1.86788e+07), 5).tick_width == 100_000);
133 }
134 
135 private struct Ticks
136 {
137     double currentPosition;
138     Axis axis;
139 
140     @property double front()
141     {
142         import std.math : abs;
143         if (currentPosition >= axis.max)
144             return axis.max;
145         // Special case for zero, because a small numerical error results in
146         // wrong label, i.e. 0 + small numerical error (of 5.5e-17) is 
147         // displayed as 5.5e-17, while any other numerical error falls 
148         // away in rounding
149         if (abs(currentPosition - 0) < axis.tick_width/1.0e5)
150             return 0.0;
151         return currentPosition;
152     }
153 
154     void popFront()
155     {
156         if (currentPosition < axis.min_tick)
157             currentPosition = axis.min_tick;
158         else
159             currentPosition += axis.tick_width;
160     }
161 
162     @property bool empty()
163     {
164         if (currentPosition - axis.tick_width >= axis.max)
165             return true;
166         return false;
167     }
168 }
169 
170 /// Returns a range starting at axis.min, ending axis.max and with
171 /// all the tick locations in between
172 auto axisTicks(Axis axis)
173 {
174     return Ticks(axis.min, axis);
175 }
176 
177 unittest
178 {
179     import std.array : array, front, back;
180 
181     auto ax1 = adjustTickWidth(Axis(0, .4), 5).axisTicks;
182     auto ax2 = adjustTickWidth(Axis(0, 4), 8).axisTicks;
183     assertEqual(ax1.array.front, 0);
184     assertEqual(ax1.array.back, .4);
185     assertEqual(ax2.array.front, 0);
186     assertEqual(ax2.array.back, 4);
187     assertGreaterThan(ax1.array.length, 3);
188     assertLessThan(ax1.array.length, 8);
189 
190     assertGreaterThan(ax2.array.length, 5);
191     assertLessThan(ax2.array.length, 10);
192 
193     auto ax3 = adjustTickWidth(Axis(1.1, 2), 5).axisTicks;
194     assertEqual(ax3.array.front, 1.1);
195     assertEqual(ax3.array.back, 2);
196 }
197 
198 /// Calculate tick length
199 double tickLength(in Axis axis)
200 {
201     return (axis.max - axis.min) / 25.0;
202 }
203 
204 unittest
205 {
206     auto axis = Axis(-1, 1);
207     assert(tickLength(axis) == 0.08);
208 }
209 
210 /// Convert a value to an axis label
211 string toAxisLabel( double value )
212 {
213     import std.math : abs, round;
214     import std.format : format;
215     if (abs(value) > 1 && abs(value) < 100_000)
216     {
217         auto rv = round(value);
218         auto dec = abs(round((value - rv)*100));
219         if (dec == 0)
220             return format( "%s", rv );
221         else if (dec%10 == 0)
222             return format( "%s.%s", rv, dec/10);
223         else
224             return format( "%s.%s", rv, dec);
225     }
226     return format( "%.3g", value );
227 }
228 
229 unittest
230 {
231     assertEqual( 5.toAxisLabel, "5" );
232     assertEqual( (0.5).toAxisLabel, "0.5" );
233     assertEqual( (0.001234567).toAxisLabel, "0.00123" );
234     assertEqual( (0.00000001234567).toAxisLabel, "1.23e-08" );
235     assertEqual( (2001).toAxisLabel, "2001" );
236     assertEqual( (2001.125).toAxisLabel, "2001.13" );
237     assertEqual( (-2001).toAxisLabel, "-2001" );
238     assertEqual( (-2001.125).toAxisLabel, "-2001.13" );
239     assertEqual( (-2.301).toAxisLabel, "-2.3" );
240 }
241 
242 /// Calculate tick length in plot units
243 auto tickLength(double plotSize, size_t deviceSize, double scalingX, double scalingY)
244 {
245     // We want ticks to be same size irrespcetvie of aspect ratio
246     auto scaling = (scalingX+scalingY)/2.0;
247     return scaling*10.0*plotSize/deviceSize;
248 }
249 
250 unittest
251 {
252     assertEqual(tickLength(10.0, 100, 1, 0.5), tickLength(10.0, 100, 0.5, 1));
253     assertEqual(tickLength(10.0, 100, 1, 0.5), 2.0*tickLength(5.0, 100, 0.5, 1));
254 }
255 
256 /// Aes describing the axis and its tick locations
257 auto axisAes(string type, double minC, double maxC, double lvl, double scaling = 1, Tuple!(double, string)[] ticks = [])
258 {
259     import std.algorithm : sort, uniq, map;
260     import std.array : array;
261     import std.conv : to;
262     import std.range : empty, repeat, take, popFront, walkLength;
263 
264     import ggplotd.aes : Aes;
265 
266     double[] ticksLoc;
267     auto sortedAxisTicks = ticks.sort().uniq;
268 
269     string[] labels;
270 
271     if (!sortedAxisTicks.empty)
272     {
273         ticksLoc = [minC] ~ sortedAxisTicks.map!((t) => t[0]).array ~ [maxC];
274         labels = [""] ~ sortedAxisTicks.map!((t) {
275             if (t[1].empty)
276                 return t[0].to!double.toAxisLabel;
277             else
278                 return t[1];
279         }).array ~ [""];
280     }
281     else
282     {
283         import std.math : round;
284         import std.conv : to;
285         ticksLoc = Axis(minC, maxC).adjustTickWidth(round(6.0*scaling).to!size_t).axisTicks.array;
286         labels = ticksLoc.map!((a) => a.to!double.toAxisLabel).array;
287     }
288 
289     if (type == "x")
290     {
291         return Aes!(double[], "x", double[], "y", string[], "label", double[], "angle",
292             double[], "size")(
293             ticksLoc, lvl.repeat().take(ticksLoc.walkLength).array, labels,
294             (0.0).repeat(labels.walkLength).array,
295             (scaling).repeat(labels.walkLength).array);
296     }
297     else
298     {
299         import std.math : PI;
300 
301         return Aes!(double[], "x", double[], "y", string[], "label", double[], "angle",
302             double[], "size")(
303             lvl.repeat().take(ticksLoc.walkLength).array, ticksLoc, labels,
304             ((-0.5 * PI).to!double).repeat(labels.walkLength).array,
305             (scaling).repeat(labels.walkLength).array);
306     }
307 }
308 
309 unittest
310 {
311     import std.stdio : writeln;
312 
313     auto aes = axisAes("x", 0.0, 1.0, 2.0);
314     assertEqual(aes.front.x, 0.0);
315     assertEqual(aes.front.y, 2.0);
316     assertEqual(aes.front.label, "0");
317 
318     aes = axisAes("y", 0.0, 1.0, 2.0, 1.0, [Tuple!(double, string)(0.2, "lbl")]);
319     aes.popFront;
320     assertEqual(aes.front.x, 2.0);
321     assertEqual(aes.front.y, 0.2);
322     assertEqual(aes.front.label, "lbl");
323 }
324 
325 private string ctReplaceAll( string orig, string pattern, string replacement )
326 {
327 
328     import std.string : split;
329     auto spl = orig.split( pattern );
330     string str = spl[0];
331     foreach( sp; spl[1..$] )
332         str ~= replacement ~ sp;
333     return str;
334 }
335 
336 // Create a specialised x and y axis version of a given function.
337 private string xy( string func )
338 {
339     import std.format : format;
340     return format( "///\n%s\n\n///\n%s",
341         func
342             .ctReplaceAll( "axis", "xaxis" )
343             .ctReplaceAll( "Axis", "XAxis" ),
344         func
345             .ctReplaceAll( "axis", "yaxis" )
346             .ctReplaceAll( "Axis", "YAxis" ) );
347 }
348 
349 alias XAxisFunction = XAxis delegate(XAxis);
350 alias YAxisFunction = YAxis delegate(YAxis);
351 
352 // Below are the external functions to be used by library users.
353 
354 // Set the range of an axis
355 mixin( xy( q{auto axisRange( double min, double max ) 
356 { 
357     AxisFunction func = ( Axis axis ) { axis.min = min; axis.max = max; return axis; }; 
358     return func;
359 }} ) );
360 
361 ///
362 unittest
363 {
364     XAxis ax;
365     auto f = xaxisRange( 0, 1 );
366     assertEqual( f(ax).min, 0 );
367     assertEqual( f(ax).max, 1 );
368 
369     YAxis yax;
370     auto yf = yaxisRange( 0, 1 );
371     assertEqual( yf(yax).min, 0 );
372     assertEqual( yf(yax).max, 1 );
373 }
374 
375 // Set the label of an axis
376 mixin( xy( q{auto axisLabel( string label ) 
377 { 
378     // Need to declare it as an X/YAxisFunction for the GGPlotD + overload
379     AxisFunction func = ( Axis axis ) { axis.label = label; return axis; }; 
380     return func;
381 }} ) );
382 
383 ///
384 unittest
385 {
386     XAxis xax;
387     auto xf = xaxisLabel( "x" );
388     assertEqual( xf(xax).label, "x" );
389 
390     YAxis yax;
391     auto yf = yaxisLabel( "y" );
392     assertEqual( yf(yax).label, "y" );
393 }
394 
395 // Set the range of an axis
396 mixin( xy( q{auto axisOffset( double offset ) 
397 { 
398     AxisFunction func = ( Axis axis ) { axis.offset = offset; return axis; }; 
399     return func;
400 }} ) );
401 
402 ///
403 unittest
404 {
405     XAxis xax;
406     auto xf = xaxisOffset( 1 );
407     assertEqual( xf(xax).offset, 1 );
408 
409     YAxis yax;
410     auto yf = yaxisOffset( 2 );
411     assertEqual( yf(yax).offset, 2 );
412 }
413 
414 // Hide the axis 
415 mixin( xy( q{auto axisShow( bool show ) 
416 { 
417     // Need to declare it as an X/YAxisFunction for the GGPlotD + overload
418     AxisFunction func = ( Axis axis ) { axis.show = show; return axis; }; 
419     return func;
420 }} ) );
421