1 module ggplotd.guide; 2 3 version (unittest) 4 { 5 import dunit.toolkit; 6 } 7 8 private struct DiscreteStoreWithOffset 9 { 10 import std.typecons : Tuple, tuple; 11 size_t[string] store; 12 Tuple!(double, double)[] offsets; 13 14 bool put(in DiscreteStoreWithOffset ds) 15 { 16 bool added = false; 17 foreach(el, offset1, offset2; ds.data) 18 { 19 if (this.put(el, offset1)) 20 added = true; 21 if (this.put(el, offset2)) 22 added = true; 23 } 24 return added; 25 } 26 27 bool put(string el, double offset = 0) 28 { 29 import ggplotd.algorithm : safeMin, safeMax; 30 if (el !in store) 31 { 32 store[el] = store.length; 33 offsets ~= tuple(offset, offset); 34 _min = safeMin(store.length - 1 + offset, _min); 35 _max = safeMax(store.length - 1 + offset, _max); 36 return true; 37 } else { 38 auto id = store[el]; 39 offsets[id] = tuple(safeMin(offsets[id][0], offset), 40 safeMax(offsets[id][1], offset)); 41 _min = safeMin(id + offsets[id][0], _min); 42 _max = safeMax(id + offsets[id][1], _max); 43 } 44 return false; 45 } 46 47 double min() const 48 { 49 if (store.length == 0) 50 return 0; 51 return _min; 52 } 53 54 double max() const 55 { 56 if (store.length == 0) 57 return 0; 58 return _max; 59 } 60 61 auto data() const 62 { 63 import std.array : array; 64 import std.algorithm : map, sort; 65 auto kv = store.byKeyValue().array; 66 auto sorted = kv.sort!((a, b) => a.value < b.value); 67 return sorted.map!((a) => tuple(a.key, offsets[a.value][0], offsets[a.value][1])); 68 } 69 70 auto length() const 71 { 72 return offsets.length; 73 } 74 75 double _min; 76 double _max; 77 } 78 79 unittest 80 { 81 DiscreteStoreWithOffset ds; 82 assertEqual(ds.min(), 0); 83 assertEqual(ds.max(), 0); 84 85 ds.put("b", 0.5); 86 assertEqual(ds.min(), 0.5); 87 assertEqual(ds.max(), 0.5); 88 89 ds.put("a", 0.5); 90 assertEqual(ds.min(), 0.5); 91 assertEqual(ds.max(), 1.5); 92 93 ds.put("b", -0.5); 94 assertEqual(ds.min(), -0.5); 95 assertEqual(ds.max(), 1.5); 96 97 ds.put("c", -0.7); 98 assertEqual(ds.min(), -0.5); 99 assertEqual(ds.max(), 1.5); 100 101 DiscreteStoreWithOffset ds2; 102 ds2.put("d", 0.5); 103 ds2.put("b", -1.0); 104 ds.put(ds2); 105 assertEqual(ds.min(), -1.0); 106 assertEqual(ds.max(), 3.5); 107 } 108 109 /// Store values so we can later create guides from them 110 package struct GuideStore(string type = "") 111 { 112 import std.range : isInputRange; 113 /// Put another GuideStore into the store 114 void put(T)(in T gs) 115 if (is(T==GuideStore!(type))) 116 { 117 _store.put(gs._store); 118 119 import ggplotd.algorithm : safeMin, safeMax; 120 _min = safeMin(_min, gs._min); 121 _max = safeMax(_max, gs._max); 122 } 123 124 /// Add a range of values to the store 125 void put(T)(in T range) 126 if (!is(T==string) && isInputRange!T) 127 { 128 foreach(t; range) 129 this.put(t); 130 } 131 132 import std.traits : TemplateOf; 133 /// Add a value of anytype to the store 134 void put(T)(in T value, double offset = 0) 135 if (!is(T==GuideStore!(type)) && 136 (is(T==string) || !isInputRange!T) 137 ) 138 { 139 import std.conv : to; 140 import std.traits : isNumeric; 141 // For now we can just ignore colour I think 142 static if (isNumeric!T) 143 { 144 import ggplotd.algorithm : safeMin, safeMax; 145 _min = safeMin(_min, value.to!double + offset); 146 _max = safeMax(_max, value.to!double + offset); 147 } else { 148 static if (type == "colour") 149 { 150 import ggplotd.colourspace : isColour; 151 static if (!isColour!T) { 152 static if (is(T==string)) { 153 auto col = namedColour(value); 154 if (col.isNull) 155 { 156 _store.put(value, offset); 157 } 158 } else { 159 _store.put(value.to!string, offset); 160 } 161 } 162 } else { 163 _store.put(value.to!string, offset); 164 } 165 } 166 } 167 168 /// Minimum value encountered till now 169 double min() const 170 { 171 import std.math : isNaN; 172 import ggplotd.algorithm : safeMin; 173 if (_store.length > 0 || isNaN(_min)) 174 return safeMin(_store.min, _min); 175 return _min; 176 } 177 178 /// Maximum value encountered till now 179 double max() const 180 { 181 import std.math : isNaN; 182 import ggplotd.algorithm : safeMax; 183 if (_store.length > 0 || isNaN(_max)) 184 return safeMax(_store.max, _max); 185 return _max; 186 } 187 188 /// The discete values in the store 189 @property auto store() const 190 { 191 import std.algorithm : map; 192 return _store.data.map!((a) => a[0]); 193 } 194 195 /// A hash mapping the discrete values to continuous (double) 196 @property auto storeHash() const 197 { 198 import std.conv : to; 199 double[string] hash; 200 foreach(k, v; _store.store) 201 { 202 hash[k] = v.to!double; 203 } 204 return hash; 205 } 206 207 /// True if we encountered discrete values 208 bool hasDiscrete() const 209 { 210 return _store.length > 0; 211 } 212 213 double _min; 214 double _max; 215 216 DiscreteStoreWithOffset _store; // Should really only store uniques 217 218 static if (type == "colour") 219 { 220 import ggplotd.colour : namedColour; 221 } 222 } 223 224 unittest 225 { 226 import std.array : array; 227 import std.math : isNaN; 228 import std.range : walkLength; 229 // Not numeric -> add as string 230 GuideStore!"" gs; 231 gs.put("b"); 232 gs.put("b"); 233 assertEqual(gs.store.walkLength, 1); 234 gs.put("a"); 235 assertEqual(gs.store.walkLength, 2); 236 gs.put("b"); 237 assertEqual(gs.store.walkLength, 2); 238 assertEqual(gs.store.array, ["b", "a"]); 239 assertEqual(gs.storeHash, ["b":0.0, "a":1.0]); 240 assertEqual(gs.min, 0); 241 assertEqual(gs.max, 1); 242 243 // Numeric -> add as min or max (also test int) 244 gs.put(-1); 245 assertEqual(gs.min, -1.0); 246 assertEqual(gs.max, 1.0); 247 gs.put(3.0); 248 assertEqual(gs.min, -1.0); 249 assertEqual(gs.max, 3.0); 250 gs.put(1.5); 251 assertEqual(gs.min, -1.0); 252 assertEqual(gs.max, 3.0); 253 254 import ggplotd.colour: RGBA; 255 GuideStore!"colour" gsc; 256 // Test colour is ignored 257 gsc.put(RGBA(0, 0, 0, 0)); 258 assertEqual(gsc.store.walkLength, 0); 259 // Test named colour is ignored 260 gsc.put("red"); 261 assertEqual(gsc.store.walkLength, 0); 262 assertEqual(gsc.min, 0); 263 assertEqual(gsc.max, 0); 264 gsc.put("b"); 265 assertEqual(gsc.store.walkLength, 1); 266 267 // Colour not ignored for standard gc 268 gs.put(RGBA(0, 0, 0, 0)); 269 assertEqual(gs.store.walkLength, 3); 270 // Test named colour is ignored 271 gs.put("red"); 272 assertEqual(gs.store.walkLength, 4); 273 274 275 GuideStore!"" gs2; 276 gs2.put(2); 277 assertEqual(gs2.min, 2); 278 assertEqual(gs2.max, 2); 279 280 GuideStore!"" gs3; 281 gs3.put(-2); 282 assertEqual(gs3.min, -2); 283 assertEqual(gs3.max, -2); 284 } 285 286 unittest 287 { 288 GuideStore!"" gs; 289 gs.put(["a", "b", "a"]); 290 import std.array : array; 291 import std.range : walkLength; 292 assertEqual(gs.store.walkLength, 2); 293 294 GuideStore!"" gs2; 295 gs2.put(["c", "b", "a"]); 296 gs.put(gs2); 297 assertEqual(gs.store.walkLength, 3); 298 assertEqual(gs.store.array, ["a","b","c"]); 299 gs2.put([10.1,-0.1]); 300 gs.put(gs2); 301 assertEqual(gs.min, -0.1); 302 assertEqual(gs.max, 10.1); 303 304 GuideStore!"" gs3; 305 gs3.put(["a", "b", "a"]); 306 const(GuideStore!"") cst_gs() { 307 GuideStore!"" gs; 308 gs.put(["c", "b", "a"]); 309 return gs; 310 } 311 gs3.put(cst_gs()); 312 assertEqual(gs3.store.walkLength, 3); 313 assertEqual(gs3.store.array, ["a","b","c"]); 314 315 } 316 317 unittest 318 { 319 GuideStore!"" gs; 320 gs.put("a", 0.5); 321 assertEqual(gs.min(), 0.5); 322 assertEqual(gs.max(), 0.5); 323 324 GuideStore!"" gs2; 325 gs2.put("b", 0.7); 326 gs.put(gs2); 327 assertEqual(gs.min(), 0.5); 328 assertEqual(gs.max(), 1.7); 329 330 GuideStore!"" gs3; 331 gs3.put("b", -0.7); 332 gs.put(gs3); 333 import std.math : isClose; 334 assert(isClose(gs.min(), 0.3)); 335 assert(isClose(gs.max(), 1.7)); 336 } 337 338 /// A callable struct that translates any value into a double 339 struct GuideToDoubleFunction 340 { 341 /// Convert the value to double 342 private auto convert(T)(in T value, bool scale = true) const 343 { 344 import std.conv : to; 345 import std.traits : isNumeric; 346 double result; 347 static if (isNumeric!T) { 348 result = doubleConvert(value.to!double); 349 } else { 350 result = stringConvert(value.to!string); 351 } 352 return result; 353 } 354 355 auto unscaled(T)(in T value) const 356 { 357 return this.convert!T(value, false); 358 } 359 360 /// Call the function with a value 361 auto opCall(T)(in T value, bool scale = true) const 362 { 363 auto result = unscaled!T(value); 364 if (scaleFunction.isNull || !scale) 365 return result; 366 else 367 return scaleFunction.get()(result); 368 } 369 370 /// Function that governs translation from double to double (continuous to continuous) 371 double delegate(double) doubleConvert; 372 /// Function that governs translation from string to double (discrete to continuous) 373 double delegate(string) stringConvert; 374 375 import std.typecons : Nullable; 376 /// Additional scaling of the field (i.e. log10, polar coordinates) 377 Nullable!(double delegate(double)) scaleFunction; 378 } 379 380 /// A callable struct that translates any value into a colour 381 struct GuideToColourFunction 382 { 383 /// Call the function with a value 384 auto opCall(T)(in T value, bool scale = true) const 385 { 386 import std.conv : to; 387 import std.traits : isNumeric; 388 static if (isNumeric!T) { 389 return doubleConvert(toDouble(value)); 390 } else { 391 static if (isColour!T) { 392 import ggplotd.colourspace : RGBA, toColourSpace; 393 return value.toColourSpace!RGBA; 394 } else { 395 static if (is(T==string)) { 396 auto col = namedColour(value); 397 if (!col.isNull) 398 return RGBA(col.get().r, col.get().g, col.get().b, 1); 399 else 400 return stringConvert(value); 401 } else { 402 return stringConvert(value.to!string); 403 } 404 } 405 } 406 } 407 408 auto toDouble(T)(in T value, bool scale = true) const 409 { 410 import std.conv : to; 411 import std.traits : isNumeric; 412 double result = this.unscaled(value); 413 if (scaleFunction.isNull || !scale) 414 return result; 415 else 416 return scaleFunction.get()(result); 417 } 418 419 auto unscaled(T)(in T value) const 420 { 421 import std.conv : to; 422 import std.traits : isNumeric; 423 double result; 424 static if (isNumeric!T) 425 result = value.to!double; 426 else 427 result = stringToDoubleConvert(value.to!string); 428 return result; 429 } 430 431 /// Function that governs translation from double to colour (continuous to colour) 432 RGBA delegate(double) doubleConvert; 433 /// Function that governs translation from string to colour (discrete to colour) 434 RGBA delegate(string) stringConvert; 435 436 /// Function that governs translation from string to double (discrete to continuous) 437 double delegate(string) stringToDoubleConvert; 438 import ggplotd.colourspace : isColour; 439 import ggplotd.colour : namedColour, RGBA; 440 441 import std.typecons : Nullable; 442 /// Additional scaling of the field (i.e. log10, polar coordinates) 443 Nullable!(double delegate(double)) scaleFunction; 444 } 445 446 /// Create an appropiate GuidToDoubleFunction from a GuideStore 447 auto guideFunction(string type)(GuideStore!type gs) 448 if (type != "colour") 449 { 450 GuideToDoubleFunction gf; 451 static if (type == "size") { 452 gf.doubleConvert = (a) { 453 import std.math : isNaN; 454 if (isNaN(a)) 455 return a; 456 assert(a >= gs.min() || a <= gs.max(), "Value falls outside of range"); 457 if (gs.min() < 0.4 || gs.max() > 5.0) // Limit the size to between these values 458 { 459 if (gs.max() == gs.min()) 460 return 1.0; 461 return 0.7 + a*(5.0 - 0.7)/(gs.max() - gs.min()); 462 } 463 return a; 464 }; 465 466 } else { 467 gf.doubleConvert = (a) { 468 import std.math : isNaN; 469 if (isNaN(a)) 470 return a; 471 assert(a >= gs.min() || a <= gs.max(), "Value falls outside of range"); 472 return a; 473 }; 474 475 } 476 immutable storeHash = gs.storeHash; 477 478 gf.stringConvert = (a) { 479 assert(a in storeHash, "Value not in guide"); 480 return gf.doubleConvert(storeHash[a]); 481 }; 482 return gf; 483 } 484 485 unittest 486 { 487 GuideStore!"" gs; 488 gs.put(["b","a"]); 489 auto gf = guideFunction(gs); 490 assertEqual(gf(0.1), 0.1); 491 assertEqual(gf("a"), 1); 492 493 import std.math : isNaN; 494 assert(isNaN(gf(double.init))); 495 } 496 497 unittest 498 { 499 GuideStore!"size" gs; 500 gs.put( [0.5, 4] ); 501 auto gf = guideFunction(gs); 502 assertEqual(gf(0.6), 0.6); 503 504 gs.put( [0.0] ); 505 auto gf2 = guideFunction(gs); 506 assertEqual(gf2(0.0), 0.7); 507 assertEqual(gf2(4.0), 5.0); 508 509 GuideStore!"size" gs3; 510 gs3.put( [0.0] ); 511 auto gf3 = guideFunction(gs3); 512 assertEqual(gf3(0.0), 1.0); 513 } 514 515 import ggplotd.colour : ColourGradientFunction; 516 /// Create an appropiate GuidToColourFunction from a GuideStore 517 auto guideFunction(string type)(GuideStore!type gs, ColourGradientFunction colourFunction) 518 if (type == "colour") 519 { 520 GuideToColourFunction gc; 521 gc.doubleConvert = (a) { 522 import std.math : isNaN; 523 if (isNaN(a)) { 524 import ggplotd.colourspace : RGBA; 525 return RGBA(0,0,0,0); 526 } 527 assert(a >= gs.min() || a <= gs.max(), "Value falls outside of range"); 528 return colourFunction(a, gs.min(), gs.max()); 529 }; 530 531 immutable storeHash = gs.storeHash; 532 533 gc.stringToDoubleConvert = (a) { 534 assert(a in storeHash, "Value not in storeHash"); 535 return storeHash[a]; 536 }; 537 538 gc.stringConvert = (a) { 539 assert(a in storeHash, "Value not in storeHash"); 540 return gc.doubleConvert(gc.stringToDoubleConvert(a)); 541 }; 542 return gc; 543 } 544 545 unittest 546 { 547 import ggplotd.colour : colourGradient, namedColour; 548 import ggplotd.colourspace : HCY, RGBA, toTuple; 549 GuideStore!"colour" gs; 550 gs.put([0.1, 3.0]); 551 auto gf = guideFunction(gs, colourGradient!HCY("blue-red")); 552 assertEqual(gf(0.1).toTuple, namedColour("blue").get().toTuple); 553 assertEqual(gf(3.0).toTuple, namedColour("red").get().toTuple); 554 assertEqual(gf("green").toTuple, namedColour("green").get().toTuple); 555 assertEqual(gf(namedColour("green").get()).toTuple, namedColour("green").get().toTuple); 556 assertEqual(gf(double.init).toTuple, RGBA(0,0,0,0).toTuple); 557 }