diff --git a/__tests__/data/heatmap.json b/__tests__/data/heatmap.json new file mode 100644 index 0000000000..f01f362a2e --- /dev/null +++ b/__tests__/data/heatmap.json @@ -0,0 +1,2502 @@ +[ + { + "g": 541, + "l": 85, + "tmp": 858 + }, + { + "g": 937, + "l": 465, + "tmp": 299 + }, + { + "g": 566, + "l": 131, + "tmp": 326 + }, + { + "g": 738, + "l": 130, + "tmp": 262 + }, + { + "g": 85, + "l": 207, + "tmp": 184 + }, + { + "g": 321, + "l": 273, + "tmp": 312 + }, + { + "g": 552, + "l": 153, + "tmp": 367 + }, + { + "g": 698, + "l": 454, + "tmp": 194 + }, + { + "g": 409, + "l": 440, + "tmp": 754 + }, + { + "g": 905, + "l": 84, + "tmp": 891 + }, + { + "g": 186, + "l": 127, + "tmp": 55 + }, + { + "g": 636, + "l": 117, + "tmp": 97 + }, + { + "g": 990, + "l": 29, + "tmp": 549 + }, + { + "g": 284, + "l": 79, + "tmp": 570 + }, + { + "g": 28, + "l": 313, + "tmp": 320 + }, + { + "g": 146, + "l": 204, + "tmp": 206 + }, + { + "g": 257, + "l": 327, + "tmp": 540 + }, + { + "g": 277, + "l": 412, + "tmp": 766 + }, + { + "g": 352, + "l": 343, + "tmp": 944 + }, + { + "g": 875, + "l": 76, + "tmp": 789 + }, + { + "g": 969, + "l": 160, + "tmp": 934 + }, + { + "g": 909, + "l": 153, + "tmp": 779 + }, + { + "g": 227, + "l": 194, + "tmp": 471 + }, + { + "g": 618, + "l": 316, + "tmp": 400 + }, + { + "g": 910, + "l": 98, + "tmp": 289 + }, + { + "g": 718, + "l": 181, + "tmp": 409 + }, + { + "g": 285, + "l": 448, + "tmp": 894 + }, + { + "g": 89, + "l": 303, + "tmp": 567 + }, + { + "g": 900, + "l": 195, + "tmp": 132 + }, + { + "g": 780, + "l": 453, + "tmp": 741 + }, + { + "g": 337, + "l": 243, + "tmp": 715 + }, + { + "g": 156, + "l": 206, + "tmp": 720 + }, + { + "g": 238, + "l": 11, + "tmp": 244 + }, + { + "g": 303, + "l": 174, + "tmp": 320 + }, + { + "g": 607, + "l": 234, + "tmp": 1 + }, + { + "g": 460, + "l": 127, + "tmp": 176 + }, + { + "g": 85, + "l": 435, + "tmp": 548 + }, + { + "g": 938, + "l": 198, + "tmp": 423 + }, + { + "g": 336, + "l": 387, + "tmp": 607 + }, + { + "g": 331, + "l": 1, + "tmp": 535 + }, + { + "g": 320, + "l": 342, + "tmp": 635 + }, + { + "g": 620, + "l": 178, + "tmp": 345 + }, + { + "g": 20, + "l": 475, + "tmp": 561 + }, + { + "g": 878, + "l": 427, + "tmp": 375 + }, + { + "g": 42, + "l": 412, + "tmp": 115 + }, + { + "g": 383, + "l": 12, + "tmp": 422 + }, + { + "g": 821, + "l": 442, + "tmp": 756 + }, + { + "g": 737, + "l": 319, + "tmp": 676 + }, + { + "g": 810, + "l": 180, + "tmp": 834 + }, + { + "g": 45, + "l": 11, + "tmp": 552 + }, + { + "g": 119, + "l": 422, + "tmp": 801 + }, + { + "g": 634, + "l": 198, + "tmp": 816 + }, + { + "g": 980, + "l": 168, + "tmp": 44 + }, + { + "g": 595, + "l": 496, + "tmp": 188 + }, + { + "g": 729, + "l": 100, + "tmp": 88 + }, + { + "g": 635, + "l": 479, + "tmp": 362 + }, + { + "g": 40, + "l": 384, + "tmp": 441 + }, + { + "g": 334, + "l": 238, + "tmp": 231 + }, + { + "g": 351, + "l": 362, + "tmp": 724 + }, + { + "g": 70, + "l": 217, + "tmp": 816 + }, + { + "g": 515, + "l": 245, + "tmp": 567 + }, + { + "g": 842, + "l": 209, + "tmp": 703 + }, + { + "g": 496, + "l": 226, + "tmp": 720 + }, + { + "g": 998, + "l": 52, + "tmp": 863 + }, + { + "g": 43, + "l": 51, + "tmp": 622 + }, + { + "g": 253, + "l": 210, + "tmp": 610 + }, + { + "g": 775, + "l": 491, + "tmp": 748 + }, + { + "g": 766, + "l": 157, + "tmp": 804 + }, + { + "g": 302, + "l": 202, + "tmp": 489 + }, + { + "g": 463, + "l": 126, + "tmp": 761 + }, + { + "g": 308, + "l": 88, + "tmp": 996 + }, + { + "g": 432, + "l": 239, + "tmp": 247 + }, + { + "g": 793, + "l": 96, + "tmp": 759 + }, + { + "g": 297, + "l": 173, + "tmp": 428 + }, + { + "g": 637, + "l": 437, + "tmp": 465 + }, + { + "g": 296, + "l": 490, + "tmp": 667 + }, + { + "g": 586, + "l": 172, + "tmp": 202 + }, + { + "g": 435, + "l": 100, + "tmp": 103 + }, + { + "g": 110, + "l": 399, + "tmp": 936 + }, + { + "g": 130, + "l": 19, + "tmp": 36 + }, + { + "g": 54, + "l": 442, + "tmp": 692 + }, + { + "g": 106, + "l": 71, + "tmp": 770 + }, + { + "g": 804, + "l": 365, + "tmp": 450 + }, + { + "g": 106, + "l": 5, + "tmp": 264 + }, + { + "g": 730, + "l": 139, + "tmp": 520 + }, + { + "g": 305, + "l": 320, + "tmp": 888 + }, + { + "g": 994, + "l": 230, + "tmp": 71 + }, + { + "g": 503, + "l": 401, + "tmp": 408 + }, + { + "g": 394, + "l": 210, + "tmp": 945 + }, + { + "g": 142, + "l": 176, + "tmp": 85 + }, + { + "g": 840, + "l": 470, + "tmp": 940 + }, + { + "g": 649, + "l": 243, + "tmp": 872 + }, + { + "g": 260, + "l": 427, + "tmp": 986 + }, + { + "g": 403, + "l": 333, + "tmp": 522 + }, + { + "g": 261, + "l": 174, + "tmp": 145 + }, + { + "g": 581, + "l": 109, + "tmp": 877 + }, + { + "g": 377, + "l": 363, + "tmp": 979 + }, + { + "g": 242, + "l": 49, + "tmp": 85 + }, + { + "g": 241, + "l": 201, + "tmp": 807 + }, + { + "g": 859, + "l": 120, + "tmp": 167 + }, + { + "g": 11, + "l": 413, + "tmp": 102 + }, + { + "g": 866, + "l": 35, + "tmp": 642 + }, + { + "g": 226, + "l": 288, + "tmp": 208 + }, + { + "g": 578, + "l": 449, + "tmp": 27 + }, + { + "g": 224, + "l": 386, + "tmp": 263 + }, + { + "g": 909, + "l": 230, + "tmp": 342 + }, + { + "g": 828, + "l": 252, + "tmp": 136 + }, + { + "g": 246, + "l": 336, + "tmp": 825 + }, + { + "g": 376, + "l": 227, + "tmp": 914 + }, + { + "g": 416, + "l": 313, + "tmp": 318 + }, + { + "g": 453, + "l": 184, + "tmp": 513 + }, + { + "g": 633, + "l": 382, + "tmp": 486 + }, + { + "g": 327, + "l": 188, + "tmp": 498 + }, + { + "g": 617, + "l": 25, + "tmp": 193 + }, + { + "g": 283, + "l": 224, + "tmp": 949 + }, + { + "g": 400, + "l": 18, + "tmp": 533 + }, + { + "g": 948, + "l": 123, + "tmp": 57 + }, + { + "g": 514, + "l": 477, + "tmp": 663 + }, + { + "g": 532, + "l": 405, + "tmp": 225 + }, + { + "g": 723, + "l": 357, + "tmp": 743 + }, + { + "g": 810, + "l": 170, + "tmp": 57 + }, + { + "g": 362, + "l": 275, + "tmp": 371 + }, + { + "g": 642, + "l": 421, + "tmp": 48 + }, + { + "g": 934, + "l": 431, + "tmp": 622 + }, + { + "g": 45, + "l": 39, + "tmp": 642 + }, + { + "g": 401, + "l": 401, + "tmp": 660 + }, + { + "g": 678, + "l": 257, + "tmp": 225 + }, + { + "g": 696, + "l": 201, + "tmp": 854 + }, + { + "g": 807, + "l": 372, + "tmp": 673 + }, + { + "g": 119, + "l": 402, + "tmp": 474 + }, + { + "g": 498, + "l": 142, + "tmp": 414 + }, + { + "g": 900, + "l": 338, + "tmp": 10 + }, + { + "g": 176, + "l": 143, + "tmp": 577 + }, + { + "g": 909, + "l": 134, + "tmp": 427 + }, + { + "g": 42, + "l": 443, + "tmp": 247 + }, + { + "g": 667, + "l": 97, + "tmp": 860 + }, + { + "g": 497, + "l": 463, + "tmp": 842 + }, + { + "g": 252, + "l": 364, + "tmp": 853 + }, + { + "g": 191, + "l": 40, + "tmp": 501 + }, + { + "g": 471, + "l": 334, + "tmp": 913 + }, + { + "g": 877, + "l": 157, + "tmp": 70 + }, + { + "g": 198, + "l": 24, + "tmp": 131 + }, + { + "g": 709, + "l": 434, + "tmp": 110 + }, + { + "g": 740, + "l": 352, + "tmp": 658 + }, + { + "g": 406, + "l": 322, + "tmp": 716 + }, + { + "g": 304, + "l": 288, + "tmp": 718 + }, + { + "g": 765, + "l": 241, + "tmp": 374 + }, + { + "g": 566, + "l": 224, + "tmp": 299 + }, + { + "g": 316, + "l": 284, + "tmp": 147 + }, + { + "g": 167, + "l": 213, + "tmp": 783 + }, + { + "g": 550, + "l": 314, + "tmp": 903 + }, + { + "g": 10, + "l": 266, + "tmp": 175 + }, + { + "g": 711, + "l": 408, + "tmp": 938 + }, + { + "g": 691, + "l": 338, + "tmp": 274 + }, + { + "g": 523, + "l": 36, + "tmp": 298 + }, + { + "g": 910, + "l": 17, + "tmp": 201 + }, + { + "g": 40, + "l": 125, + "tmp": 91 + }, + { + "g": 106, + "l": 233, + "tmp": 603 + }, + { + "g": 297, + "l": 64, + "tmp": 165 + }, + { + "g": 723, + "l": 178, + "tmp": 127 + }, + { + "g": 785, + "l": 93, + "tmp": 614 + }, + { + "g": 672, + "l": 465, + "tmp": 63 + }, + { + "g": 843, + "l": 301, + "tmp": 507 + }, + { + "g": 766, + "l": 450, + "tmp": 647 + }, + { + "g": 84, + "l": 225, + "tmp": 358 + }, + { + "g": 947, + "l": 478, + "tmp": 131 + }, + { + "g": 202, + "l": 455, + "tmp": 193 + }, + { + "g": 548, + "l": 399, + "tmp": 819 + }, + { + "g": 199, + "l": 62, + "tmp": 182 + }, + { + "g": 362, + "l": 229, + "tmp": 277 + }, + { + "g": 704, + "l": 358, + "tmp": 428 + }, + { + "g": 124, + "l": 24, + "tmp": 414 + }, + { + "g": 655, + "l": 363, + "tmp": 213 + }, + { + "g": 897, + "l": 184, + "tmp": 37 + }, + { + "g": 727, + "l": 296, + "tmp": 667 + }, + { + "g": 299, + "l": 305, + "tmp": 23 + }, + { + "g": 920, + "l": 39, + "tmp": 776 + }, + { + "g": 822, + "l": 195, + "tmp": 400 + }, + { + "g": 694, + "l": 229, + "tmp": 770 + }, + { + "g": 886, + "l": 342, + "tmp": 485 + }, + { + "g": 757, + "l": 375, + "tmp": 441 + }, + { + "g": 730, + "l": 170, + "tmp": 595 + }, + { + "g": 734, + "l": 238, + "tmp": 580 + }, + { + "g": 671, + "l": 11, + "tmp": 979 + }, + { + "g": 688, + "l": 37, + "tmp": 685 + }, + { + "g": 310, + "l": 492, + "tmp": 576 + }, + { + "g": 245, + "l": 488, + "tmp": 683 + }, + { + "g": 378, + "l": 293, + "tmp": 553 + }, + { + "g": 783, + "l": 45, + "tmp": 573 + }, + { + "g": 948, + "l": 93, + "tmp": 351 + }, + { + "g": 921, + "l": 17, + "tmp": 893 + }, + { + "g": 539, + "l": 372, + "tmp": 232 + }, + { + "g": 555, + "l": 31, + "tmp": 424 + }, + { + "g": 282, + "l": 435, + "tmp": 336 + }, + { + "g": 334, + "l": 305, + "tmp": 928 + }, + { + "g": 33, + "l": 160, + "tmp": 644 + }, + { + "g": 610, + "l": 446, + "tmp": 123 + }, + { + "g": 750, + "l": 209, + "tmp": 856 + }, + { + "g": 257, + "l": 259, + "tmp": 990 + }, + { + "g": 815, + "l": 21, + "tmp": 461 + }, + { + "g": 351, + "l": 130, + "tmp": 625 + }, + { + "g": 406, + "l": 367, + "tmp": 344 + }, + { + "g": 26, + "l": 207, + "tmp": 786 + }, + { + "g": 814, + "l": 93, + "tmp": 93 + }, + { + "g": 218, + "l": 311, + "tmp": 981 + }, + { + "g": 394, + "l": 310, + "tmp": 130 + }, + { + "g": 767, + "l": 110, + "tmp": 838 + }, + { + "g": 741, + "l": 369, + "tmp": 23 + }, + { + "g": 988, + "l": 241, + "tmp": 109 + }, + { + "g": 273, + "l": 80, + "tmp": 516 + }, + { + "g": 487, + "l": 457, + "tmp": 817 + }, + { + "g": 245, + "l": 135, + "tmp": 128 + }, + { + "g": 424, + "l": 228, + "tmp": 517 + }, + { + "g": 818, + "l": 219, + "tmp": 385 + }, + { + "g": 512, + "l": 225, + "tmp": 608 + }, + { + "g": 482, + "l": 113, + "tmp": 352 + }, + { + "g": 598, + "l": 148, + "tmp": 755 + }, + { + "g": 484, + "l": 168, + "tmp": 471 + }, + { + "g": 739, + "l": 187, + "tmp": 583 + }, + { + "g": 484, + "l": 363, + "tmp": 820 + }, + { + "g": 99, + "l": 61, + "tmp": 891 + }, + { + "g": 121, + "l": 340, + "tmp": 904 + }, + { + "g": 968, + "l": 321, + "tmp": 554 + }, + { + "g": 851, + "l": 474, + "tmp": 482 + }, + { + "g": 998, + "l": 414, + "tmp": 133 + }, + { + "g": 980, + "l": 21, + "tmp": 911 + }, + { + "g": 519, + "l": 381, + "tmp": 99 + }, + { + "g": 707, + "l": 55, + "tmp": 651 + }, + { + "g": 563, + "l": 422, + "tmp": 5 + }, + { + "g": 23, + "l": 495, + "tmp": 421 + }, + { + "g": 613, + "l": 477, + "tmp": 557 + }, + { + "g": 735, + "l": 432, + "tmp": 314 + }, + { + "g": 441, + "l": 161, + "tmp": 447 + }, + { + "g": 896, + "l": 327, + "tmp": 30 + }, + { + "g": 673, + "l": 58, + "tmp": 826 + }, + { + "g": 346, + "l": 165, + "tmp": 68 + }, + { + "g": 451, + "l": 225, + "tmp": 116 + }, + { + "g": 981, + "l": 301, + "tmp": 50 + }, + { + "g": 141, + "l": 354, + "tmp": 922 + }, + { + "g": 759, + "l": 301, + "tmp": 350 + }, + { + "g": 521, + "l": 145, + "tmp": 193 + }, + { + "g": 873, + "l": 441, + "tmp": 470 + }, + { + "g": 794, + "l": 41, + "tmp": 990 + }, + { + "g": 545, + "l": 302, + "tmp": 70 + }, + { + "g": 241, + "l": 98, + "tmp": 81 + }, + { + "g": 271, + "l": 495, + "tmp": 179 + }, + { + "g": 910, + "l": 190, + "tmp": 678 + }, + { + "g": 727, + "l": 380, + "tmp": 275 + }, + { + "g": 310, + "l": 133, + "tmp": 523 + }, + { + "g": 839, + "l": 474, + "tmp": 324 + }, + { + "g": 16, + "l": 453, + "tmp": 842 + }, + { + "g": 402, + "l": 80, + "tmp": 634 + }, + { + "g": 953, + "l": 370, + "tmp": 261 + }, + { + "g": 440, + "l": 369, + "tmp": 109 + }, + { + "g": 887, + "l": 184, + "tmp": 239 + }, + { + "g": 309, + "l": 105, + "tmp": 303 + }, + { + "g": 63, + "l": 360, + "tmp": 199 + }, + { + "g": 999, + "l": 455, + "tmp": 141 + }, + { + "g": 928, + "l": 257, + "tmp": 572 + }, + { + "g": 750, + "l": 3, + "tmp": 18 + }, + { + "g": 266, + "l": 309, + "tmp": 350 + }, + { + "g": 40, + "l": 52, + "tmp": 588 + }, + { + "g": 448, + "l": 203, + "tmp": 256 + }, + { + "g": 283, + "l": 264, + "tmp": 125 + }, + { + "g": 238, + "l": 31, + "tmp": 231 + }, + { + "g": 819, + "l": 461, + "tmp": 669 + }, + { + "g": 84, + "l": 22, + "tmp": 796 + }, + { + "g": 605, + "l": 284, + "tmp": 296 + }, + { + "g": 431, + "l": 86, + "tmp": 21 + }, + { + "g": 926, + "l": 182, + "tmp": 552 + }, + { + "g": 195, + "l": 468, + "tmp": 526 + }, + { + "g": 807, + "l": 188, + "tmp": 764 + }, + { + "g": 816, + "l": 326, + "tmp": 696 + }, + { + "g": 314, + "l": 125, + "tmp": 648 + }, + { + "g": 952, + "l": 50, + "tmp": 999 + }, + { + "g": 894, + "l": 112, + "tmp": 454 + }, + { + "g": 670, + "l": 437, + "tmp": 440 + }, + { + "g": 878, + "l": 494, + "tmp": 637 + }, + { + "g": 259, + "l": 278, + "tmp": 871 + }, + { + "g": 461, + "l": 449, + "tmp": 264 + }, + { + "g": 15, + "l": 50, + "tmp": 17 + }, + { + "g": 770, + "l": 151, + "tmp": 622 + }, + { + "g": 167, + "l": 59, + "tmp": 387 + }, + { + "g": 315, + "l": 412, + "tmp": 907 + }, + { + "g": 393, + "l": 110, + "tmp": 162 + }, + { + "g": 197, + "l": 71, + "tmp": 394 + }, + { + "g": 306, + "l": 354, + "tmp": 183 + }, + { + "g": 593, + "l": 113, + "tmp": 736 + }, + { + "g": 214, + "l": 249, + "tmp": 611 + }, + { + "g": 214, + "l": 78, + "tmp": 589 + }, + { + "g": 521, + "l": 218, + "tmp": 571 + }, + { + "g": 149, + "l": 299, + "tmp": 939 + }, + { + "g": 841, + "l": 379, + "tmp": 510 + }, + { + "g": 197, + "l": 127, + "tmp": 355 + }, + { + "g": 187, + "l": 340, + "tmp": 356 + }, + { + "g": 793, + "l": 171, + "tmp": 138 + }, + { + "g": 340, + "l": 184, + "tmp": 597 + }, + { + "g": 702, + "l": 317, + "tmp": 313 + }, + { + "g": 439, + "l": 383, + "tmp": 217 + }, + { + "g": 337, + "l": 137, + "tmp": 251 + }, + { + "g": 916, + "l": 99, + "tmp": 703 + }, + { + "g": 636, + "l": 405, + "tmp": 524 + }, + { + "g": 203, + "l": 234, + "tmp": 478 + }, + { + "g": 36, + "l": 138, + "tmp": 928 + }, + { + "g": 876, + "l": 20, + "tmp": 636 + }, + { + "g": 790, + "l": 97, + "tmp": 553 + }, + { + "g": 551, + "l": 73, + "tmp": 74 + }, + { + "g": 258, + "l": 296, + "tmp": 766 + }, + { + "g": 278, + "l": 219, + "tmp": 387 + }, + { + "g": 540, + "l": 309, + "tmp": 422 + }, + { + "g": 686, + "l": 418, + "tmp": 577 + }, + { + "g": 192, + "l": 184, + "tmp": 625 + }, + { + "g": 921, + "l": 317, + "tmp": 593 + }, + { + "g": 544, + "l": 24, + "tmp": 911 + }, + { + "g": 699, + "l": 344, + "tmp": 381 + }, + { + "g": 416, + "l": 92, + "tmp": 334 + }, + { + "g": 233, + "l": 60, + "tmp": 462 + }, + { + "g": 157, + "l": 377, + "tmp": 642 + }, + { + "g": 937, + "l": 483, + "tmp": 890 + }, + { + "g": 501, + "l": 402, + "tmp": 277 + }, + { + "g": 246, + "l": 270, + "tmp": 467 + }, + { + "g": 433, + "l": 118, + "tmp": 427 + }, + { + "g": 788, + "l": 499, + "tmp": 214 + }, + { + "g": 706, + "l": 332, + "tmp": 164 + }, + { + "g": 366, + "l": 315, + "tmp": 909 + }, + { + "g": 392, + "l": 272, + "tmp": 755 + }, + { + "g": 817, + "l": 393, + "tmp": 512 + }, + { + "g": 192, + "l": 189, + "tmp": 237 + }, + { + "g": 761, + "l": 50, + "tmp": 361 + }, + { + "g": 600, + "l": 267, + "tmp": 783 + }, + { + "g": 911, + "l": 369, + "tmp": 579 + }, + { + "g": 55, + "l": 206, + "tmp": 299 + }, + { + "g": 646, + "l": 92, + "tmp": 5 + }, + { + "g": 587, + "l": 311, + "tmp": 213 + }, + { + "g": 87, + "l": 428, + "tmp": 206 + }, + { + "g": 753, + "l": 87, + "tmp": 804 + }, + { + "g": 454, + "l": 483, + "tmp": 996 + }, + { + "g": 906, + "l": 268, + "tmp": 708 + }, + { + "g": 885, + "l": 179, + "tmp": 99 + }, + { + "g": 549, + "l": 274, + "tmp": 734 + }, + { + "g": 348, + "l": 154, + "tmp": 997 + }, + { + "g": 672, + "l": 319, + "tmp": 941 + }, + { + "g": 415, + "l": 118, + "tmp": 901 + }, + { + "g": 390, + "l": 252, + "tmp": 580 + }, + { + "g": 532, + "l": 346, + "tmp": 216 + }, + { + "g": 878, + "l": 19, + "tmp": 669 + }, + { + "g": 326, + "l": 309, + "tmp": 797 + }, + { + "g": 496, + "l": 497, + "tmp": 348 + }, + { + "g": 243, + "l": 189, + "tmp": 725 + }, + { + "g": 140, + "l": 310, + "tmp": 396 + }, + { + "g": 343, + "l": 433, + "tmp": 472 + }, + { + "g": 342, + "l": 133, + "tmp": 969 + }, + { + "g": 947, + "l": 137, + "tmp": 966 + }, + { + "g": 710, + "l": 423, + "tmp": 231 + }, + { + "g": 770, + "l": 296, + "tmp": 43 + }, + { + "g": 557, + "l": 173, + "tmp": 268 + }, + { + "g": 66, + "l": 178, + "tmp": 392 + }, + { + "g": 960, + "l": 455, + "tmp": 266 + }, + { + "g": 225, + "l": 147, + "tmp": 396 + }, + { + "g": 679, + "l": 384, + "tmp": 211 + }, + { + "g": 757, + "l": 66, + "tmp": 94 + }, + { + "g": 456, + "l": 206, + "tmp": 120 + }, + { + "g": 644, + "l": 111, + "tmp": 767 + }, + { + "g": 387, + "l": 276, + "tmp": 587 + }, + { + "g": 669, + "l": 409, + "tmp": 43 + }, + { + "g": 752, + "l": 462, + "tmp": 802 + }, + { + "g": 829, + "l": 75, + "tmp": 445 + }, + { + "g": 263, + "l": 282, + "tmp": 423 + }, + { + "g": 761, + "l": 496, + "tmp": 771 + }, + { + "g": 830, + "l": 492, + "tmp": 117 + }, + { + "g": 740, + "l": 92, + "tmp": 355 + }, + { + "g": 840, + "l": 339, + "tmp": 311 + }, + { + "g": 964, + "l": 196, + "tmp": 332 + }, + { + "g": 439, + "l": 289, + "tmp": 638 + }, + { + "g": 316, + "l": 194, + "tmp": 851 + }, + { + "g": 833, + "l": 178, + "tmp": 680 + }, + { + "g": 694, + "l": 458, + "tmp": 876 + }, + { + "g": 793, + "l": 305, + "tmp": 777 + }, + { + "g": 273, + "l": 363, + "tmp": 585 + }, + { + "g": 194, + "l": 356, + "tmp": 619 + }, + { + "g": 225, + "l": 56, + "tmp": 520 + }, + { + "g": 658, + "l": 342, + "tmp": 123 + }, + { + "g": 940, + "l": 464, + "tmp": 572 + }, + { + "g": 500, + "l": 181, + "tmp": 629 + }, + { + "g": 100, + "l": 60, + "tmp": 283 + }, + { + "g": 236, + "l": 263, + "tmp": 129 + }, + { + "g": 656, + "l": 395, + "tmp": 320 + }, + { + "g": 134, + "l": 306, + "tmp": 629 + }, + { + "g": 87, + "l": 79, + "tmp": 488 + }, + { + "g": 815, + "l": 433, + "tmp": 462 + }, + { + "g": 803, + "l": 223, + "tmp": 610 + }, + { + "g": 300, + "l": 68, + "tmp": 641 + }, + { + "g": 468, + "l": 133, + "tmp": 827 + }, + { + "g": 309, + "l": 147, + "tmp": 955 + }, + { + "g": 691, + "l": 215, + "tmp": 796 + }, + { + "g": 549, + "l": 481, + "tmp": 391 + }, + { + "g": 776, + "l": 29, + "tmp": 859 + }, + { + "g": 420, + "l": 478, + "tmp": 960 + }, + { + "g": 927, + "l": 7, + "tmp": 396 + }, + { + "g": 475, + "l": 160, + "tmp": 234 + }, + { + "g": 330, + "l": 463, + "tmp": 468 + }, + { + "g": 484, + "l": 206, + "tmp": 159 + }, + { + "g": 233, + "l": 245, + "tmp": 345 + }, + { + "g": 143, + "l": 144, + "tmp": 664 + }, + { + "g": 780, + "l": 247, + "tmp": 946 + }, + { + "g": 289, + "l": 391, + "tmp": 564 + }, + { + "g": 959, + "l": 183, + "tmp": 460 + }, + { + "g": 923, + "l": 192, + "tmp": 48 + }, + { + "g": 494, + "l": 318, + "tmp": 795 + }, + { + "g": 301, + "l": 20, + "tmp": 737 + }, + { + "g": 926, + "l": 135, + "tmp": 941 + }, + { + "g": 164, + "l": 268, + "tmp": 385 + }, + { + "g": 197, + "l": 318, + "tmp": 763 + }, + { + "g": 921, + "l": 325, + "tmp": 171 + }, + { + "g": 614, + "l": 464, + "tmp": 192 + }, + { + "g": 195, + "l": 103, + "tmp": 822 + }, + { + "g": 399, + "l": 261, + "tmp": 473 + }, + { + "g": 928, + "l": 410, + "tmp": 359 + }, + { + "g": 746, + "l": 77, + "tmp": 574 + }, + { + "g": 362, + "l": 422, + "tmp": 833 + }, + { + "g": 23, + "l": 83, + "tmp": 615 + }, + { + "g": 445, + "l": 295, + "tmp": 682 + }, + { + "g": 177, + "l": 8, + "tmp": 976 + }, + { + "g": 740, + "l": 448, + "tmp": 840 + }, + { + "g": 95, + "l": 265, + "tmp": 208 + }, + { + "g": 423, + "l": 278, + "tmp": 145 + }, + { + "g": 336, + "l": 255, + "tmp": 42 + }, + { + "g": 718, + "l": 207, + "tmp": 806 + }, + { + "g": 669, + "l": 171, + "tmp": 124 + }, + { + "g": 235, + "l": 64, + "tmp": 978 + }, + { + "g": 945, + "l": 167, + "tmp": 749 + }, + { + "g": 280, + "l": 294, + "tmp": 165 + }, + { + "g": 979, + "l": 0, + "tmp": 745 + }, + { + "g": 3, + "l": 101, + "tmp": 646 + }, + { + "g": 611, + "l": 91, + "tmp": 990 + }, + { + "g": 966, + "l": 97, + "tmp": 778 + }, + { + "g": 335, + "l": 51, + "tmp": 487 + }, + { + "g": 562, + "l": 354, + "tmp": 171 + }, + { + "g": 261, + "l": 161, + "tmp": 377 + }, + { + "g": 178, + "l": 309, + "tmp": 813 + }, + { + "g": 726, + "l": 344, + "tmp": 208 + }, + { + "g": 801, + "l": 194, + "tmp": 854 + }, + { + "g": 25, + "l": 136, + "tmp": 703 + }, + { + "g": 218, + "l": 210, + "tmp": 849 + }, + { + "g": 181, + "l": 19, + "tmp": 311 + }, + { + "g": 522, + "l": 392, + "tmp": 282 + }, + { + "g": 875, + "l": 360, + "tmp": 260 + }, + { + "g": 978, + "l": 481, + "tmp": 389 + }, + { + "g": 885, + "l": 335, + "tmp": 294 + }, + { + "g": 218, + "l": 270, + "tmp": 566 + }, + { + "g": 362, + "l": 316, + "tmp": 349 + }, + { + "g": 95, + "l": 359, + "tmp": 674 + }, + { + "g": 767, + "l": 184, + "tmp": 985 + }, + { + "g": 791, + "l": 378, + "tmp": 827 + }, + { + "g": 799, + "l": 426, + "tmp": 716 + }, + { + "g": 437, + "l": 183, + "tmp": 409 + }, + { + "g": 305, + "l": 85, + "tmp": 451 + }, + { + "g": 859, + "l": 357, + "tmp": 382 + }, + { + "g": 90, + "l": 61, + "tmp": 621 + }, + { + "g": 933, + "l": 347, + "tmp": 62 + }, + { + "g": 693, + "l": 208, + "tmp": 123 + }, + { + "g": 304, + "l": 222, + "tmp": 809 + }, + { + "g": 72, + "l": 243, + "tmp": 116 + }, + { + "g": 657, + "l": 9, + "tmp": 441 + }, + { + "g": 65, + "l": 199, + "tmp": 147 + }, + { + "g": 503, + "l": 208, + "tmp": 86 + }, + { + "g": 257, + "l": 274, + "tmp": 221 + }, + { + "g": 879, + "l": 32, + "tmp": 269 + }, + { + "g": 979, + "l": 432, + "tmp": 344 + }, + { + "g": 948, + "l": 52, + "tmp": 80 + }, + { + "g": 973, + "l": 181, + "tmp": 811 + }, + { + "g": 584, + "l": 438, + "tmp": 394 + }, + { + "g": 645, + "l": 357, + "tmp": 89 + }, + { + "g": 387, + "l": 457, + "tmp": 20 + }, + { + "g": 686, + "l": 240, + "tmp": 829 + }, + { + "g": 419, + "l": 185, + "tmp": 722 + }, + { + "g": 869, + "l": 365, + "tmp": 455 + }, + { + "g": 376, + "l": 434, + "tmp": 586 + }, + { + "g": 219, + "l": 418, + "tmp": 619 + }, + { + "g": 458, + "l": 186, + "tmp": 808 + }, + { + "g": 26, + "l": 427, + "tmp": 922 + }, + { + "g": 61, + "l": 116, + "tmp": 688 + }, + { + "g": 247, + "l": 243, + "tmp": 645 + }, + { + "g": 990, + "l": 93, + "tmp": 844 + }, + { + "g": 757, + "l": 273, + "tmp": 81 + }, + { + "g": 396, + "l": 387, + "tmp": 15 + }, + { + "g": 518, + "l": 425, + "tmp": 268 + }, + { + "g": 246, + "l": 48, + "tmp": 423 + }, + { + "g": 841, + "l": 426, + "tmp": 530 + }, + { + "g": 844, + "l": 69, + "tmp": 571 + }, + { + "g": 818, + "l": 31, + "tmp": 27 + }, + { + "g": 248, + "l": 180, + "tmp": 168 + }, + { + "g": 251, + "l": 185, + "tmp": 462 + }, + { + "g": 727, + "l": 57, + "tmp": 15 + }, + { + "g": 636, + "l": 175, + "tmp": 7 + }, + { + "g": 310, + "l": 127, + "tmp": 494 + }, + { + "g": 380, + "l": 442, + "tmp": 609 + }, + { + "g": 923, + "l": 192, + "tmp": 43 + }, + { + "g": 161, + "l": 487, + "tmp": 817 + }, + { + "g": 596, + "l": 320, + "tmp": 718 + } +] \ No newline at end of file diff --git a/__tests__/integration/snapshots/static/DiamondHeatmapDensity.png b/__tests__/integration/snapshots/static/DiamondHeatmapDensity.png new file mode 100644 index 0000000000..d61c55eba7 Binary files /dev/null and b/__tests__/integration/snapshots/static/DiamondHeatmapDensity.png differ diff --git a/__tests__/integration/snapshots/static/HeatmapHeatmapBasic.png b/__tests__/integration/snapshots/static/HeatmapHeatmapBasic.png new file mode 100644 index 0000000000..3f429498f5 Binary files /dev/null and b/__tests__/integration/snapshots/static/HeatmapHeatmapBasic.png differ diff --git a/__tests__/integration/utils/renderSpec.ts b/__tests__/integration/utils/renderSpec.ts index 827a3584b3..1b67c132ac 100644 --- a/__tests__/integration/utils/renderSpec.ts +++ b/__tests__/integration/utils/renderSpec.ts @@ -1,4 +1,5 @@ import { Canvas } from '@antv/g'; +import { createCanvas } from 'canvas'; import { G2Context, G2Spec, render } from '../../../src'; import { renderToMountedElement } from '../../utils/renderToMountedElement'; import { createNodeGCanvas } from './createNodeGCanvas'; @@ -14,6 +15,11 @@ export async function renderSpec( const renderFunction = mounted ? renderToMountedElement : render; const options = preprocess({ ...raw, width, height }); context.canvas = gCanvas; + context.createCanvas = () => { + // The width attribute defaults to 300, and the height attribute defaults to 150. + // @see https://stackoverflow.com/a/12019582 + return createCanvas(300, 150) as unknown as HTMLCanvasElement; + }; await new Promise((resolve) => // @ts-ignore renderFunction({ theme: 'classic', ...options }, context, resolve), diff --git a/__tests__/plots/static/diamond-heatmap-density.ts b/__tests__/plots/static/diamond-heatmap-density.ts new file mode 100644 index 0000000000..d888129ae4 --- /dev/null +++ b/__tests__/plots/static/diamond-heatmap-density.ts @@ -0,0 +1,64 @@ +import DataSet from '@antv/data-set'; +import { G2Spec } from '../../../src'; + +export function DiamondHeatmapDensity(): G2Spec { + return { + type: 'view', + data: { + type: 'fetch', + value: 'data/diamond.csv', + }, + scale: { + x: { nice: true, domainMin: -0.5 }, + y: { nice: true, domainMin: -2000 }, + color: { nice: true }, + }, + children: [ + { + type: 'heatmap', + data: { + transform: [ + { + type: 'custom', + callback: (data) => { + const dv = new DataSet.View().source(data); + // @ts-ignore + dv.transform({ + type: 'kernel-smooth.density', + fields: ['carat', 'price'], + as: ['carat', 'price', 'density'], + }); + return dv.rows; + }, + }, + ], + }, + encode: { + x: 'carat', + y: 'price', + color: 'density', + }, + style: { + opacity: 0.3, + gradient: [ + [0, 'white'], + [0.2, 'blue'], + [0.4, 'cyan'], + [0.6, 'lime'], + [0.8, 'yellow'], + [0.9, 'red'], + ], + }, + }, + { + type: 'point', + encode: { + x: 'carat', + y: 'price', + }, + }, + ], + }; +} + +DiamondHeatmapDensity.maxError = 100; diff --git a/__tests__/plots/static/heatmap-heatmap-basic.ts b/__tests__/plots/static/heatmap-heatmap-basic.ts new file mode 100644 index 0000000000..857d135777 --- /dev/null +++ b/__tests__/plots/static/heatmap-heatmap-basic.ts @@ -0,0 +1,35 @@ +import { G2Spec } from '../../../src'; + +export function HeatmapHeatmapBasic(): G2Spec { + return { + type: 'view', + padding: 0, + children: [ + { + type: 'image', + style: { + src: 'https://gw.alipayobjects.com/zos/rmsportal/NeUTMwKtPcPxIFNTWZOZ.png', + x: '50%', + y: '50%', + width: '100%', + height: '100%', + }, + }, + { + type: 'heatmap', + data: { + type: 'fetch', + value: 'data/heatmap.json', + }, + encode: { + x: 'g', + y: 'l', + color: 'tmp', + }, + axis: false, + }, + ], + }; +} + +HeatmapHeatmapBasic.maxError = 100; diff --git a/__tests__/plots/static/index.ts b/__tests__/plots/static/index.ts index 6150d6ac1b..8e3090670f 100644 --- a/__tests__/plots/static/index.ts +++ b/__tests__/plots/static/index.ts @@ -228,3 +228,5 @@ export { vaccinesCellScaleRelationAutoPaddingTickFilter } from './vaccines-cell- export { marketIntervalMarimekkoAutoPaddingFlex } from './market-interval-marimekko-auto-padding-flex'; export { aaplIntervalDateEncodeX } from './aapl-interval-date-encode-x'; export { athletesRectBinLegendStyle } from './athletes-rect-bin-legend-style'; +export { HeatmapHeatmapBasic } from './heatmap-heatmap-basic'; +export { DiamondHeatmapDensity } from './diamond-heatmap-density'; diff --git a/__tests__/unit/api/chart.spec.ts b/__tests__/unit/api/chart.spec.ts index 725f1dd9f8..9a22da52a9 100644 --- a/__tests__/unit/api/chart.spec.ts +++ b/__tests__/unit/api/chart.spec.ts @@ -38,6 +38,8 @@ import { Tree, WordCloud, Gauge, + Density, + Heatmap, } from '../../../src/api/mark/mark'; const TEST_OPTIONS = { @@ -162,6 +164,8 @@ describe('Chart', () => { expect(chart.tree()).toBeInstanceOf(Tree); expect(chart.wordCloud()).toBeInstanceOf(WordCloud); expect(chart.gauge()).toBeInstanceOf(Gauge); + expect(chart.density()).toBeInstanceOf(Density); + expect(chart.heatmap()).toBeInstanceOf(Heatmap); expect(chart.options().children).toEqual([ { type: 'interval' }, { type: 'rect' }, @@ -190,6 +194,8 @@ describe('Chart', () => { { type: 'tree' }, { type: 'wordCloud' }, { type: 'gauge' }, + { type: 'density' }, + { type: 'heatmap' }, ]); }); diff --git a/__tests__/unit/api/composition.spec.ts b/__tests__/unit/api/composition.spec.ts index 52b0e5044d..db45a360d2 100644 --- a/__tests__/unit/api/composition.spec.ts +++ b/__tests__/unit/api/composition.spec.ts @@ -34,6 +34,8 @@ import { ForceGraph, Tree, WordCloud, + Density, + Heatmap, } from '../../../src/api/mark/mark'; function expectToCreateMarks(node) { @@ -61,6 +63,8 @@ function expectToCreateMarks(node) { expect(node.forceGraph()).toBeInstanceOf(ForceGraph); expect(node.tree()).toBeInstanceOf(Tree); expect(node.wordCloud()).toBeInstanceOf(WordCloud); + expect(node.density()).toBeInstanceOf(Density); + expect(node.heatmap()).toBeInstanceOf(Heatmap); } function expectToCreateCompositions(node) { diff --git a/__tests__/unit/stdlib/index.spec.ts b/__tests__/unit/stdlib/index.spec.ts index 5f4fa27244..ca9e85d2e2 100644 --- a/__tests__/unit/stdlib/index.spec.ts +++ b/__tests__/unit/stdlib/index.spec.ts @@ -41,6 +41,7 @@ import { WordCloud as WordCloudGeometry, Gauge, Density as DensityGeometry, + Heatmap, } from '../../../src/mark'; import { Category10, Category20 } from '../../../src/palette'; import { @@ -111,6 +112,7 @@ import { Path as PathShape, HollowPath, Density as DensityShape, + Heatmap as HeatmapShape, Shape as CustomShape, } from '../../../src/shape'; import { Classic, ClassicDark, Academy } from '../../../src/theme'; @@ -370,6 +372,7 @@ describe('stdlib', () => { 'mark.wordCloud': WordCloudGeometry, 'mark.density': DensityGeometry, 'mark.gauge': Gauge, + 'mark.heatmap': Heatmap, 'palette.category10': Category10, 'palette.category20': Category20, 'scale.linear': Linear, @@ -446,6 +449,7 @@ describe('stdlib', () => { 'shape.path.path': PathShape, 'shape.path.hollow': HollowPath, 'shape.density.density': DensityShape, + 'shape.heatmap.heatmap': HeatmapShape, 'theme.classic': Classic, 'theme.classicDark': ClassicDark, 'theme.academy': Academy, diff --git a/package.json b/package.json index dbb48e7da2..0177a97e6c 100644 --- a/package.json +++ b/package.json @@ -64,9 +64,11 @@ "d3-scale-chromatic": "^3.0.0", "d3-shape": "^3.1.0", "d3-voronoi": "^1.1.4", + "flru": "^1.0.2", "pdfast": "^0.2.0" }, "devDependencies": { + "@antv/data-set": "^0.11.8", "@antv/g-plugin-rough-canvas-renderer": "^1.7.9", "@antv/g-plugin-rough-svg-renderer": "^1.7.9", "@antv/g-svg": "^1.8.6", diff --git a/site/.dumi/global.ts b/site/.dumi/global.ts index d4497f206e..e9f813a2de 100644 --- a/site/.dumi/global.ts +++ b/site/.dumi/global.ts @@ -24,6 +24,8 @@ if (window) { (window as any).gWebgl = require('@antv/g-webgl'); (window as any).fecha = require('fecha'); (window as any).React = require('react'); + (window as any).dataSet = require('@antv/data-set'); + (window as any).lodash = require('lodash'); } if ( diff --git a/site/docs/api/chart.zh.md b/site/docs/api/chart.zh.md index c79b66bb8c..fb4e2c530d 100644 --- a/site/docs/api/chart.zh.md +++ b/site/docs/api/chart.zh.md @@ -128,6 +128,10 @@ chart.render(); 添加 density 图形,具体见 [mark](/spec/mark/density)。 +### `chart.heatmap` + +添加 heatmap 图形,具体见 [mark](/spec/mark/heatmap)。 + ### `chart.shape` 添加 shape 图形,具体见 [mark](/spec/mark/shape)。 diff --git a/site/docs/api/overview.zh.md b/site/docs/api/overview.zh.md index 712e5bb32a..dce66c2369 100644 --- a/site/docs/api/overview.zh.md +++ b/site/docs/api/overview.zh.md @@ -30,6 +30,7 @@ order: 1 - [chart.**treemap**](/api/chart#charttreemap) - 添加 treemap 标记到该视图。 - [chart.**boxplot**](/api/chart#chartboxplot) - 添加 boxplot 标记到该视图。 - [chart.**density**](/api/chart#density) - 添加 density 标记到该视图。 +- [chart.**heatmap**](/api/chart#heatmap) - 添加 heatmap 标记到该视图。 - [chart.**shape**](/api/chart#chartshape) - 添加 shape 标记到该视图。 - [chart.**pack**](/api/chart#chartpack) - 添加 pack 标记到该视图。 - [chart.**forceGraph**](/api/chart#chartforcegraph) - 添加 forceGraph 标记到该视图。 diff --git a/site/docs/api/view.zh.md b/site/docs/api/view.zh.md index 2cfdeadde8..71b6c9a965 100644 --- a/site/docs/api/view.zh.md +++ b/site/docs/api/view.zh.md @@ -133,6 +133,10 @@ chart.render(); 添加 density 图形,具体见 [mark](/spec/mark/density)。 +### `chart.heatmap` + +添加 heatmap 图形,具体见 [mark](/spec/mark/heatmap)。 + ### `view.shape` 添加 shape 图形,具体见 [mark](/spec/mark/shape)。 diff --git a/site/docs/spec/mark/heatmap.en.md b/site/docs/spec/mark/heatmap.en.md new file mode 100644 index 0000000000..fbc61a8df4 --- /dev/null +++ b/site/docs/spec/mark/heatmap.en.md @@ -0,0 +1,6 @@ +--- +title: heatmap +order: 1 +--- + + diff --git a/site/docs/spec/mark/heatmap.zh.md b/site/docs/spec/mark/heatmap.zh.md new file mode 100644 index 0000000000..ba72753541 --- /dev/null +++ b/site/docs/spec/mark/heatmap.zh.md @@ -0,0 +1,87 @@ +--- +title: heatmap +order: 1 +--- + +用于绘制密度热力图,使用的时候,需要指定数据通道 encode 中的 `x`,`y`,`color`。 + +## 开始使用 + +heatmap + +```js +import { Chart } from '@antv/g2'; + +const chart = new Chart({ + container: 'container', + theme: 'classic', + autoFit: true, + padding: 0, +}); + +chart.axis(false); + +chart + .image() + .style( + 'src', + 'https://gw.alipayobjects.com/zos/rmsportal/NeUTMwKtPcPxIFNTWZOZ.png', + ) + .style('x', '50%') + .style('y', '50%') + .style('width', '100%') + .style('height', '100%') + .tooltip(false); + +chart + .heatmap() + .data({ + type: 'fetch', + value: 'https://assets.antv.antgroup.com/g2/heatmap.json', + }) + .encode('x', 'g') + .encode('y', 'l') + .encode('color', 'tmp') + .style('opacity', 0) + .tooltip(false); + +chart.render(); +``` + +## 选项 + +对于 heatmap 图形的样式配置中,主要有以下: + + +| 属性 | 描述 | 类型 | 默认值 | +| -------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | ------------------------------ | +| gradient | 图形对应的渐变色配置 | `string` \| `Array<[number, string]>` | - | +| opacity | 热力图的透明度 ,如果设置,则会覆盖 `maxOpacity`, `minOpacity` 配置,范围 0 ~ 1 | `number` | `0.6` | +| maxOpacity | 热力图像素点透明度最大值,在 `opacity = 0` 时候生效,范围 0 ~ 1 | `number` | `1` | +| minOpacity | 热力图像素点透明度最小值,在 `opacity = 0` 时候生效,范围 0 ~ 1 | `number` | `0` | +| blur | 热力图的模糊因子,范围 0 ~ 1,越大图形约平滑 | `number` | `0.85` | +| useGradientOpacity | 图形的填充色 | `boolean` | `false` | +| fill | 图形的填充色 | `string` \| `Function` | - | +| fillOpacity | 图形的填充透明度 | `number` \| `Function` | - | +| stroke | 图形的描边 | `string` \| `Function` | - | +| strokeOpacity | 描边透明度 | `number` \| `Function` | - | +| lineWidth | 图形描边的宽度 | `number` \| `Function` | - | +| lineDash | 描边的虚线配置,第一个值为虚线每个分段的长度,第二个值为分段间隔的距离。lineDash 设为[0, 0]的效果为没有描边。 | `[number,number]` \| `Function<[number, number]>` | - | +| shadowColor | 图形阴影颜色 | `string` \| `Function` | - | +| shadowBlur | 图形阴影的高斯模糊系数 | `number` \| `Function` | - | +| shadowOffsetX | 设置阴影距图形的水平距离 | `number` \| `Function` | - | +| shadowOffsetY | 设置阴影距图形的垂直距离 | `number` \| `Function` | - | +| cursor | 鼠标样式。同 css 的鼠标样式,默认 'default'。 | `string` \| `Function` | 'default' | + +关于 `gradient` 配置,来一个示例如下,也是 G2 默认内置的渐变色: + +```ts +const gradient = [ + [0.25, 'rgb(0,0,255)'], + [0.55, 'rgb(0,255,0)'], + [0.85, 'yellow'], + [1.0, 'rgb(255,0,0)'], +]; + +const gradient = '0.25:rgb(0,0,255) 0.55:rgb(0,255,0) 0.85:yellow 1.0:rgb(255,0,0)'; +``` diff --git a/site/docs/spec/overview.zh.md b/site/docs/spec/overview.zh.md index 80390a16df..a73bc7ea29 100644 --- a/site/docs/spec/overview.zh.md +++ b/site/docs/spec/overview.zh.md @@ -51,6 +51,7 @@ G2 是一个简洁的、渐进式的可视化语法。文档将按照下面的 - [polygon](/spec/mark/polygon) - 利用多组 (x, y) 数据点,在画布中绘制闭合的多边形,通常结合一些社区布局算法使用。 - [wordcloud](/spec/mark/wordcloud) - 绘制词云图。 - [density](/spec/mark/density) - 渲染核密度数据,多用于小提琴图。 +- [heatmap](/spec/mark/heatmap) - 接受热力数据,多用于绘制热力图。 ## Transform diff --git a/site/examples/general/heatmap/demo/heatmap-density.ts b/site/examples/general/heatmap/demo/heatmap-density.ts new file mode 100644 index 0000000000..48fa8a6277 --- /dev/null +++ b/site/examples/general/heatmap/demo/heatmap-density.ts @@ -0,0 +1,54 @@ +import DataSet from '@antv/data-set'; +import { Chart } from '@antv/g2'; + +const chart = new Chart({ + container: 'container', + theme: 'classic', + autoFit: true, +}); + +chart.data({ + type: 'fetch', + value: 'https://assets.antv.antgroup.com/g2/diamond.json', +}); + +chart.scale('x', { nice: true, domainMin: -0.5 }); +chart.scale('y', { nice: true, domainMin: -2000 }); +chart.scale('color', { nice: true }); + +chart + .heatmap() + .data({ + transform: [ + { + type: 'custom', + callback: (data) => { + const dv = new DataSet.View().source(data); + dv.transform({ + type: 'kernel-smooth.density', + fields: ['carat', 'price'], + as: ['carat', 'price', 'density'], + }); + return dv.rows; + }, + }, + ], + }) + .encode('x', 'carat') + .encode('y', 'price') + .encode('color', 'density') + .style({ + opacity: 0.3, + gradient: [ + [0, 'white'], + [0.2, 'blue'], + [0.4, 'cyan'], + [0.6, 'lime'], + [0.8, 'yellow'], + [0.9, 'red'], + ], + }); + +chart.point().encode('x', 'carat').encode('y', 'price'); + +chart.render(); diff --git a/site/examples/general/heatmap/demo/heatmap.ts b/site/examples/general/heatmap/demo/heatmap.ts new file mode 100644 index 0000000000..1bac8ee257 --- /dev/null +++ b/site/examples/general/heatmap/demo/heatmap.ts @@ -0,0 +1,36 @@ +import { Chart } from '@antv/g2'; + +const chart = new Chart({ + container: 'container', + theme: 'classic', + autoFit: true, + padding: 0, +}); + +chart.axis(false); + +chart + .image() + .style( + 'src', + 'https://gw.alipayobjects.com/zos/rmsportal/NeUTMwKtPcPxIFNTWZOZ.png', + ) + .style('x', '50%') + .style('y', '50%') + .style('width', '100%') + .style('height', '100%') + .tooltip(false); + +chart + .heatmap() + .data({ + type: 'fetch', + value: 'https://assets.antv.antgroup.com/g2/heatmap.json', + }) + .encode('x', 'g') + .encode('y', 'l') + .encode('color', 'tmp') + .style('opacity', 0) + .tooltip(false); + +chart.render(); diff --git a/site/examples/general/heatmap/demo/meta.json b/site/examples/general/heatmap/demo/meta.json new file mode 100644 index 0000000000..0b3c45728d --- /dev/null +++ b/site/examples/general/heatmap/demo/meta.json @@ -0,0 +1,32 @@ +{ + "title": { + "zh": "中文分类", + "en": "Category" + }, + "demos": [ + { + "filename": "heatmap.ts", + "title": { + "zh": "密度热力图", + "en": "Heatmap" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*ze7gSYylw_QAAAAAAAAAAAAADmJ7AQ/original" + }, + { + "filename": "heatmap-density.ts", + "title": { + "zh": "散点分布热力", + "en": "Heatmap Density" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*0AfVQpGgcsoAAAAAAAAAAAAADmJ7AQ/original" + }, + { + "filename": "mouse-heatmap.ts", + "title": { + "zh": "鼠标热力图", + "en": "Mouse Heatmap" + }, + "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*kC58R7TEdd0AAAAAAAAAAAAADmJ7AQ/original" + } + ] +} diff --git a/site/examples/general/heatmap/demo/mouse-heatmap.ts b/site/examples/general/heatmap/demo/mouse-heatmap.ts new file mode 100644 index 0000000000..f9900e394e --- /dev/null +++ b/site/examples/general/heatmap/demo/mouse-heatmap.ts @@ -0,0 +1,61 @@ +import { Chart } from '@antv/g2'; +import { throttle } from 'lodash'; + +const data = {}; + +const chart = new Chart({ + container: 'container', + theme: 'classic', + width: 640, + height: 480, + padding: 0, +}); + +chart.style({ + viewFill: '#4e79a7', +}); + +chart.data([]); +chart.axis(false); + +chart + .heatmap() + .encode('x', 'x') + .encode('y', 'y') + .encode('color', 'v') + .scale('x', { domain: [0, 640] }) + .scale('y', { domain: [0, 480], range: [0, 1] }) + .style('opacity', 0) + .tooltip(false) + .animate(false); + +chart.render(); + +chart.on( + 'plot:pointermove', + throttle((e) => { + const { x, y } = e; + + const kx = Math.floor(x - (x % 8)); + const ky = Math.floor(y - (y % 8)); + + if (!data[kx]) data[kx] = {}; + if (!data[kx][ky]) data[kx][ky] = 0; + + data[kx][ky] += 1; + + const d = transform(data); + + chart.changeData(d); + }), +); + +function transform(dataMap) { + const arr = []; + Object.keys(dataMap).forEach((x) => { + Object.keys(dataMap[x]).forEach((y) => { + arr.push({ x, y, v: dataMap[x][y] }); + }); + }); + return arr; +} diff --git a/site/examples/general/heatmap/index.en.md b/site/examples/general/heatmap/index.en.md new file mode 100644 index 0000000000..b8931f628b --- /dev/null +++ b/site/examples/general/heatmap/index.en.md @@ -0,0 +1,4 @@ +--- +title: Heatmap +order: 20 +--- \ No newline at end of file diff --git a/site/examples/general/heatmap/index.zh.md b/site/examples/general/heatmap/index.zh.md new file mode 100644 index 0000000000..be5ee203ba --- /dev/null +++ b/site/examples/general/heatmap/index.zh.md @@ -0,0 +1,4 @@ +--- +title: 热力图 +order: 20 +--- \ No newline at end of file diff --git a/site/package.json b/site/package.json index a33afa12c6..2bfd4d8fb6 100644 --- a/site/package.json +++ b/site/package.json @@ -8,6 +8,7 @@ "preview": "dumi preview" }, "dependencies": { + "@antv/data-set": "^0.11.8", "@antv/dumi-theme-antv": "^0.3.0", "@antv/g-lottie-player": "^0.0.13", "@antv/g-pattern": "^0.0.3", @@ -26,6 +27,7 @@ "d3-voronoi": "^1.1.4", "dumi": "^2.0.0", "fecha": "^4.2.3", + "lodash": "^4.17.21", "topojson": "^3.0.2", "webfontloader": "1.6.28" }, diff --git a/src/api/mark/index.ts b/src/api/mark/index.ts index 724630296c..c826cb41c8 100644 --- a/src/api/mark/index.ts +++ b/src/api/mark/index.ts @@ -22,6 +22,7 @@ import { Treemap, Boxplot, Density, + Heatmap, Shape, Pack, ForceGraph, @@ -55,6 +56,7 @@ export interface Mark { treemap(): Treemap; boxplot(): Boxplot; density(): Density; + heatmap(): Heatmap; shape(): Shape; pack(): Pack; forceGraph(): ForceGraph; @@ -89,6 +91,7 @@ export const mark = { treemap: Treemap, boxplot: Boxplot, density: Density, + heatmap: Heatmap, shape: Shape, pack: Pack, forceGraph: ForceGraph, diff --git a/src/api/mark/mark.ts b/src/api/mark/mark.ts index 5416b09d9f..5af176a643 100644 --- a/src/api/mark/mark.ts +++ b/src/api/mark/mark.ts @@ -20,6 +20,7 @@ import { SankeyMark, BoxPlotMark, DensityMark, + HeatmapMark, ShapeMark, TreemapMark, ForceGraphMark, @@ -118,6 +119,10 @@ export interface Density extends API, Density> { type: 'density'; } +export interface Heatmap extends API, Heatmap> { + type: 'heatmap'; +} + export interface Shape extends API, Shape> { type: 'shape'; } @@ -335,6 +340,13 @@ export class Density extends MarkNode { } } +@defineProps(props) +export class Heatmap extends MarkNode { + constructor() { + super({}, 'heatmap'); + } +} + @defineProps([...props, { name: 'layout', type: 'value' }]) export class Pack extends MarkNode { constructor() { diff --git a/src/mark/heatmap.ts b/src/mark/heatmap.ts new file mode 100644 index 0000000000..5f693e3ed1 --- /dev/null +++ b/src/mark/heatmap.ts @@ -0,0 +1,48 @@ +import { MarkComponent as MC, Vector2 } from '../runtime'; +import { HeatmapMark } from '../spec'; +import { + baseGeometryChannels, + basePostInference, + basePreInference, + tooltip2d, +} from './utils'; + +export type HeatmapOptions = Omit; + +/** + * Draw heatmap with gradient. + */ +export const Heatmap: MC = (options) => { + return (index, scale, value, coordinate) => { + const { x: X, y: Y, size: S, color: C } = value; + const P = Array.from(index, (i) => { + // Default size = 40. + const r = S ? +S[i] : 40; + // Warning: x, y, value, radius. + return [...coordinate.map([+X[i], +Y[i]]), C[i], r] as unknown as Vector2; + }); + + return [[0], [P]]; + }; +}; + +const shapes = ['heatmap']; + +Heatmap.props = { + defaultShape: 'heatmap', + defaultLabelShape: 'label', + composite: false, + channels: [ + ...baseGeometryChannels({ shapes }), + { name: 'x', required: true }, + { name: 'y', required: true }, + { name: 'color', scale: 'identity', required: true }, + { name: 'size' }, + ], + preInference: [ + ...basePreInference(), + { type: 'maybeZeroY' }, + { type: 'maybeZeroX' }, + ], + postInference: [...basePostInference(), ...tooltip2d()], +}; diff --git a/src/mark/index.ts b/src/mark/index.ts index 80acc1a1ca..ae5a12949b 100644 --- a/src/mark/index.ts +++ b/src/mark/index.ts @@ -27,6 +27,7 @@ export { Tree } from './tree'; export { WordCloud } from './wordCloud'; export { Gauge } from './gauge'; export { Density } from './density'; +export { Heatmap } from './heatmap'; export type { IntervalOptions } from './interval'; export type { RectOptions } from './rect'; @@ -55,3 +56,4 @@ export type { TreeOptions } from './tree'; export type { WordCloudOptions } from './wordCloud'; export type { GaugeOptions } from './gauge'; export type { DensityOptions } from './density'; +export type { HeatmapOptions } from './heatmap'; diff --git a/src/mark/text.ts b/src/mark/text.ts index eac4384991..94bbf393f7 100644 --- a/src/mark/text.ts +++ b/src/mark/text.ts @@ -15,7 +15,6 @@ export const Text: MC = (options) => { const { cartesian = false } = options; if (cartesian) return visualMark as Mark; return ((index, scale, value, coordinate) => { - if (cartesian) return visualMark(index, scale, value, coordinate); const { x: X, y: Y } = value; const offset = createBandOffset(scale, value, options); const P = Array.from(index, (i) => { diff --git a/src/mark/utils.ts b/src/mark/utils.ts index 6fa866eb6a..7918b8b41d 100644 --- a/src/mark/utils.ts +++ b/src/mark/utils.ts @@ -96,7 +96,7 @@ export function createBandOffset( }; } -function p(d) { +export function p(d) { return parseFloat(d) / 100; } diff --git a/src/runtime/plot.ts b/src/runtime/plot.ts index 91069cd93c..91191bd1f7 100644 --- a/src/runtime/plot.ts +++ b/src/runtime/plot.ts @@ -188,12 +188,12 @@ export async function plot( .attr('id', (view) => view.key) .call(applyTranslate) .each(function (view) { - plotView(view, select(this), transitions, library); + plotView(view, select(this), transitions, library, context); enterContainer.set(view, this); }), (update) => update.call(applyTranslate).each(function (view) { - plotView(view, select(this), transitions, library); + plotView(view, select(this), transitions, library, context); updateContainer.set(view, this); }), (exit) => @@ -320,7 +320,7 @@ function createUpdateView( return async (newOptions) => { const transitions = []; const [newView, newChildren] = await initializeView(newOptions, library); - plotView(newView, selection, transitions, library); + plotView(newView, selection, transitions, library, context); updateTooltip(selection, newOptions, newView, library, context); for (const child of newChildren) { plot(child, selection, library, context); @@ -671,6 +671,7 @@ async function plotView( selection: Selection, transitions: GAnimation[], library: G2Library, + context: G2Context, ): Promise { const { components, theme, layout, markState, coordinate, key, style, clip } = view; @@ -809,7 +810,13 @@ async function plotView( const { data } = state; const { key, class: cls, type } = mark; const viewNode = selection.select(`#${key}`); - const shapeFunction = createMarkShapeFunction(mark, state, view, library); + const shapeFunction = createMarkShapeFunction( + mark, + state, + view, + library, + context, + ); const enterFunction = createEnterFunction(mark, state, view, library); const updateFunction = createUpdateFunction(mark, state, view, library); const exitFunction = createExitFunction(mark, state, view, library); @@ -1225,6 +1232,7 @@ function createMarkShapeFunction( state: G2MarkState, view: G2ViewDescriptor, library: G2Library, + context: G2Context, ): ( data: Record, index: number, @@ -1262,7 +1270,7 @@ function createMarkShapeFunction( ...visualStyle, type: shapeName(mark, shape), }); - return shapeFunction(points, value, coordinate, theme, point2d); + return shapeFunction(points, value, coordinate, theme, point2d, context); }; } diff --git a/src/runtime/render.ts b/src/runtime/render.ts index 8922d4aa22..24e457b2a6 100644 --- a/src/runtime/render.ts +++ b/src/runtime/render.ts @@ -65,8 +65,10 @@ function Canvas(width: number, height: number): GCanvas { export function render( options: T, context: G2Context = {}, - resolve?: () => void, - reject?: (e: Error) => void, + resolve = (): void => {}, + reject = (e?: any): void => { + throw e; + }, ): HTMLElement { // Initialize the context if it is not provided. const { width = 640, height = 480, theme } = options; @@ -113,8 +115,10 @@ export function render( export function renderToMountedElement( options: T, context: G2Context = {}, - resolve?: () => void, - reject?: (e: Error) => void, + resolve = () => {}, + reject = (e?: any) => { + throw e; + }, ): DisplayObject { // Initialize the context if it is not provided. const { width = 640, height = 480, on } = options; @@ -126,9 +130,7 @@ export function renderToMountedElement( } = context; if (!group?.parentElement) { - throw new Error( - `renderToMountedElement can't render chart to unmounted group.`, - ); + error(`renderToMountedElement can't render chart to unmounted group.`); } const selection = select(group); diff --git a/src/runtime/types/component.ts b/src/runtime/types/component.ts index ec5ceec6aa..b01e514746 100644 --- a/src/runtime/types/component.ts +++ b/src/runtime/types/component.ts @@ -14,7 +14,7 @@ import { import { DataComponent } from './data'; import { Encode, EncodeComponent } from './encode'; import { Mark, MarkComponent } from './mark'; -import { G2ViewTree, G2Library, G2Mark } from './options'; +import { G2ViewTree, G2Library, G2Mark, G2Context } from './options'; import { Transform, TransformComponent } from './transform'; export type G2ComponentNamespaces = @@ -145,6 +145,7 @@ export type Shape = ( coordinate: Coordinate, theme: G2Theme, point2d?: Vector2[][], + context?: G2Context, ) => DisplayObject; export type ShapeProps = { defaultMarker?: string; diff --git a/src/runtime/types/options.ts b/src/runtime/types/options.ts index 6098a80acb..8bd20cafe4 100644 --- a/src/runtime/types/options.ts +++ b/src/runtime/types/options.ts @@ -57,6 +57,11 @@ export type G2Context = { group?: DisplayObject; animations?: GAnimation[]; views?: G2ViewDescriptor[]; + /** + * Tell G2 how to create a canvas-like element, some marks will use it later such as wordcloud & heatmap. + * Use `document.createElement('canvas')` instead if not provided. + */ + createCanvas?: () => HTMLCanvasElement; }; export type G2View = { diff --git a/src/shape/heatmap/heatmap.ts b/src/shape/heatmap/heatmap.ts new file mode 100644 index 0000000000..d6ebdbf4d0 --- /dev/null +++ b/src/shape/heatmap/heatmap.ts @@ -0,0 +1,85 @@ +import { max as d3max, min as d3min } from 'd3-array'; +import { Image as GImage } from '@antv/g'; +import { applyStyle, getShapeTheme } from '../utils'; +import { select } from '../../utils/selection'; +import { ShapeComponent as SC } from '../../runtime'; +import { HeatmapRenderer } from './renderer'; +import type { HeatmapRendererOptions } from './renderer/types'; + +export type HeatmapOptions = HeatmapRendererOptions; + +function deleteKey(obj: any, fn: (v, k) => boolean) { + const r = { ...obj }; + return Object.keys(obj).reduce((r, k) => { + const v = obj[k]; + if (!fn(v, k)) r[k] = v; + return r; + }, {}); +} + +export const Heatmap: SC = (options) => { + const { + gradient, + opacity, + maxOpacity, + minOpacity, + blur, + useGradientOpacity, + ...style + } = options; + return (points: number[][], value, coordinate, theme, _, context) => { + const { mark, shape, defaultShape, transform } = value; + const { ...shapeTheme } = getShapeTheme(theme, mark, shape, defaultShape); + const { createCanvas } = context; + + const [width, height] = coordinate.getSize(); + const data = points.map((p: number[]) => ({ + x: p[0], + y: p[1], + value: p[2], + radius: p[3], + })); + + const min = d3min(points, (p) => p[2]); + const max = d3max(points, (p) => p[2]); + + const options = { + gradient, + opacity, + minOpacity, + maxOpacity, + blur, + useGradientOpacity, + }; + const ctx = + width && height + ? HeatmapRenderer( + width, + height, + min, + max, + data, + deleteKey(options, (v) => v === undefined), + createCanvas, + ) + : { canvas: null }; + + return select(new GImage()) + .call(applyStyle, shapeTheme) + .style('x', 0) + .style('y', 0) + .style('width', width) + .style('height', height) + .style('src', ctx.canvas) + .style('transform', transform) + .call(applyStyle, style) + .node(); + }; +}; + +Heatmap.props = { + defaultMarker: 'point', + defaultEnterAnimation: 'fadeIn', + defaultUpdateAnimation: 'morphing', + defaultExitAnimation: 'fadeOut', +}; diff --git a/src/shape/heatmap/renderer/gradient.ts b/src/shape/heatmap/renderer/gradient.ts new file mode 100644 index 0000000000..74eb5beebe --- /dev/null +++ b/src/shape/heatmap/renderer/gradient.ts @@ -0,0 +1,16 @@ +import { HeatmapGradient } from './types'; + +/** + * Parse heatmap gradient. + */ +export function parseGradient( + gradient: HeatmapGradient, +): Array<[number, string]> { + if (typeof gradient === 'string') { + return gradient.split(' ').map((stop) => { + const [r, c] = stop.split(':'); + return [+r, c]; + }); + } + return gradient; +} diff --git a/src/shape/heatmap/renderer/index.ts b/src/shape/heatmap/renderer/index.ts new file mode 100644 index 0000000000..5bd92df3bc --- /dev/null +++ b/src/shape/heatmap/renderer/index.ts @@ -0,0 +1,193 @@ +import { lru } from '../../../utils/lru'; +import { parseGradient } from './gradient'; +import { + HeatmapGradient, + HeatmapRendererData, + HeatmapRendererOptions, +} from './types'; + +function newCanvas( + createCanvas: () => HTMLCanvasElement, + width: number, + height: number, +) { + const c = createCanvas ? createCanvas() : document.createElement('canvas'); + c.width = width; + c.height = height; + return c; +} + +/** + * Get a point with template. + * @param radius + * @param blurFactor + * @returns + */ +const getPointTemplate = lru( + ( + radius: number, + blurFactor: number, + createCanvas?: () => HTMLCanvasElement, + ) => { + const tplCanvas = newCanvas(createCanvas, radius * 2, radius * 2); + const tplCtx = tplCanvas.getContext('2d'); + const x = radius; + const y = radius; + + if (blurFactor === 1) { + tplCtx.beginPath(); + tplCtx.arc(x, y, radius, 0, 2 * Math.PI, false); + tplCtx.fillStyle = 'rgba(0,0,0,1)'; + tplCtx.fill(); + } else { + const gradient = tplCtx.createRadialGradient( + x, + y, + radius * blurFactor, + x, + y, + radius, + ); + gradient.addColorStop(0, 'rgba(0,0,0,1)'); + gradient.addColorStop(1, 'rgba(0,0,0,0)'); + tplCtx.fillStyle = gradient; + tplCtx.fillRect(0, 0, 2 * radius, 2 * radius); + } + return tplCanvas; + }, + (radius) => `${radius}`, +); + +/** + * Get a color palette with len = 256 base on gradient. + * @param gradientConfig + * @returns + */ +function getColorPalette(gradientConfig: HeatmapGradient, createCanvas) { + const paletteCanvas = newCanvas(createCanvas, 256, 1); + const paletteCtx = paletteCanvas.getContext('2d'); + + const gradient = paletteCtx.createLinearGradient(0, 0, 256, 1); + parseGradient(gradientConfig).forEach(([r, c]) => { + gradient.addColorStop(r, c); + }); + + paletteCtx.fillStyle = gradient; + paletteCtx.fillRect(0, 0, 256, 1); + + return paletteCtx.getImageData(0, 0, 256, 1).data; +} + +/** + * Draw all circle with alpha. + */ +function drawAlpha( + shadowCtx, + min: number, + max: number, + data: HeatmapRendererData[], + options: HeatmapRendererOptions, + createCanvas?: () => HTMLCanvasElement, +) { + const { blur } = options; + let len = data.length; + while (len--) { + const { x, y, value: v, radius } = data[len]; + // Ff value is bigger than max, use max as value. + const value = Math.min(v, max); + const rectX = x - radius; + const rectY = y - radius; + + const tpl = getPointTemplate(radius, 1 - blur, createCanvas); + // Value from minimum / value range, => [0, 1]. + const templateAlpha = (value - min) / (max - min); + // Small values are not visible because globalAlpha < .001 cannot be read from imageData. + shadowCtx.globalAlpha = Math.max(templateAlpha, 0.001); + shadowCtx.drawImage(tpl, rectX, rectY); + } + return shadowCtx; +} + +function colorize( + shadowCtx, + maxWidth: number, + maxHeight: number, + palette, + options: HeatmapRendererOptions, +) { + const { minOpacity, opacity, maxOpacity, useGradientOpacity } = options; + const x = 0; + const y = 0; + const width = maxWidth; + const height = maxHeight; + + const img = shadowCtx.getImageData(x, y, width, height); + const imgData = img.data; + const len = imgData.length; + + for (let i = 3; i < len; i += 4) { + const alpha = imgData[i]; + const offset = alpha * 4; + + if (!offset) { + continue; + } + + // Should be in [min, max], min >= 0. + const finalAlpha = + opacity || Math.max(0, Math.min(maxOpacity, Math.max(minOpacity, alpha))); + // Update rgba. + imgData[i - 3] = palette[offset]; + imgData[i - 2] = palette[offset + 1]; + imgData[i - 1] = palette[offset + 2]; + imgData[i] = useGradientOpacity ? palette[offset + 3] : finalAlpha; + } + + return img; +} + +/** + * Render a heatmap with canvas. + * See [heatmap.js](https://github.com/pa7/heatmap.js/blob/master/src/renderer/canvas2d.js). + */ +export function HeatmapRenderer( + width: number, + height: number, + min: number, + max: number, + data: HeatmapRendererData[], + options: HeatmapRendererOptions, + createCanvas: () => HTMLCanvasElement, +) { + const opts: HeatmapRendererOptions = { + blur: 0.85, + minOpacity: 0, + opacity: 0.6, + maxOpacity: 1, + gradient: [ + [0.25, 'rgb(0,0,255)'], + [0.55, 'rgb(0,255,0)'], + [0.85, 'yellow'], + [1.0, 'rgb(255,0,0)'], + ], + ...options, + }; + opts.minOpacity *= 255; + opts.opacity *= 255; + opts.maxOpacity *= 255; + + const shadowCanvas = newCanvas(createCanvas, width, height); + const shadowCtx = shadowCanvas.getContext('2d'); + + const palette = getColorPalette(opts.gradient, createCanvas); + + shadowCtx.clearRect(0, 0, width, height); + drawAlpha(shadowCtx, min, max, data, opts, createCanvas); + const img = colorize(shadowCtx, width, height, palette, opts); + + const canvas = newCanvas(createCanvas, width, height); + const ctx = canvas.getContext('2d'); + ctx.putImageData(img, 0, 0); + + return ctx; +} diff --git a/src/shape/heatmap/renderer/types.ts b/src/shape/heatmap/renderer/types.ts new file mode 100644 index 0000000000..96e55582aa --- /dev/null +++ b/src/shape/heatmap/renderer/types.ts @@ -0,0 +1,38 @@ +export type HeatmapGradientArray = Array<[number, string]>; +export type HeatmapGradient = string | HeatmapGradientArray; + +export type HeatmapRendererOptions = { + /** + * An gradient string that represents the gradient (syntax: number string [0,1] : color string). + */ + gradient?: HeatmapGradient; + /** + * A global opacity for the whole heatmap, default = 0.6. + * This overrides maxOpacity and minOpacity if set! + */ + opacity?: number; + /** + * The maximal opacity the highest value in the heatmap will have. (will be overridden if opacity set). + */ + maxOpacity?: number; + /** + * The minimum opacity the lowest value in the heatmap will have (will be overridden if opacity set). + */ + minOpacity?: number; + /** + * The blur factor that will be applied to all datapoints, default = 0.85. + * The higher the blur factor is, the smoother the gradients will be. + */ + blur?: number; + /** + * Use gradient opacity. + */ + useGradientOpacity?: boolean; +}; + +export type HeatmapRendererData = { + x: number; + y: number; + value: number; + radius: number; +}; diff --git a/src/shape/image/image.ts b/src/shape/image/image.ts index be233fbb57..73e278e218 100644 --- a/src/shape/image/image.ts +++ b/src/shape/image/image.ts @@ -2,6 +2,7 @@ import { Image as GImage } from '@antv/g'; import { ShapeComponent as SC } from '../../runtime'; import { applyStyle, getShapeTheme } from '../utils'; import { select } from '../../utils/selection'; +import { p } from '../../mark/utils'; export type ImageOptions = Record; @@ -16,9 +17,14 @@ export const Image: SC = (options) => { defaultShape, ); const { color = defaultColor, src = '', size = 32, transform = '' } = value; - const { width = size, height = size } = style; + let { width = size, height = size } = style; const [[x0, y0]] = points; + // Support percentage width, height. + const [w, h] = coordinate.getSize(); + width = typeof width === 'string' ? p(width) * w : width; + height = typeof height === 'string' ? p(height) * h : height; + const x = x0 - Number(width) / 2; const y = y0 - Number(height) / 2; @@ -26,12 +32,12 @@ export const Image: SC = (options) => { .call(applyStyle, shapeTheme) .style('x', x) .style('y', y) - .style('width', size) - .style('height', size) .style('img', src) .style('stroke', color) .style('transform', transform) .call(applyStyle, style) + .style('width', width) + .style('height', height) .node(); }; }; diff --git a/src/shape/index.ts b/src/shape/index.ts index 31784834ba..e55c2313bf 100644 --- a/src/shape/index.ts +++ b/src/shape/index.ts @@ -49,6 +49,7 @@ export { Label } from './label/label'; export { Path } from './path/path'; export { Hollow as HollowPath } from './path/hollow'; export { Density } from './density/density'; +export { Heatmap } from './heatmap/heatmap'; export { Shape } from './shape/shape'; export type { RectOptions } from './interval/rect'; @@ -96,6 +97,7 @@ export type { ImageOptions } from './image/image'; export type { PolygonOptions } from './polygon/polygon'; export type { BoxOptions } from './box/box'; export type { ViolinOptions } from './box/violin'; +export type { HeatmapOptions } from './heatmap/heatmap'; export type { LineOptions as LineXYOptions } from './lineXY/line'; export type { ConnectorOptions } from './connector/connector'; diff --git a/src/spec/component.ts b/src/spec/component.ts index a55c083290..1d52aeeb42 100644 --- a/src/spec/component.ts +++ b/src/spec/component.ts @@ -78,7 +78,8 @@ export type TooltipComponent = items?: TooltipItem[] | null | false; [key: string]: any; } - | null; + | null + | false; export type TooltipTitle = | string diff --git a/src/spec/composition.ts b/src/spec/composition.ts index d9f9826c64..33b7dc9f4c 100644 --- a/src/spec/composition.ts +++ b/src/spec/composition.ts @@ -5,9 +5,16 @@ import { Transform } from './transform'; import { Scale } from './scale'; import { Data } from './data'; import { LabelTransform } from './labelTransform'; -import { Literal2Object, Padding } from './utils'; -import { TitleComponent } from './component'; -import { Mark } from './mark'; +import { Closeable, Literal2Object, Padding } from './utils'; +import { + AxisComponent, + LegendComponent, + ScrollbarComponent, + SliderComponent, + TitleComponent, + TooltipComponent, +} from './component'; +import { AtheisticChanelTypes, Mark, PositionChannelTypes } from './mark'; export type Composition = | ViewComposition @@ -58,16 +65,25 @@ export type ViewComposition = { coordinate?: Coordinate; interaction?: Literal2Object; transform?: Transform[]; - title?: string | TitleComponent; theme?: Theme; children?: Mark[]; scale?: Record; frame?: boolean; labelTransform?: LabelTransform[]; - // @todo - axis?: Record; - // @todo - legend?: Record; + axis?: Closeable< + Partial>> + >; + legend?: Closeable< + Partial>> + >; + tooltip?: TooltipComponent; + slider?: Closeable< + Partial>> + >; + scrollbar?: Closeable< + Partial>> + >; + title?: string | TitleComponent; // @todo style?: Record; clip?: boolean; diff --git a/src/spec/mark.ts b/src/spec/mark.ts index 4a8119fdfa..7e9d08d547 100644 --- a/src/spec/mark.ts +++ b/src/spec/mark.ts @@ -81,6 +81,7 @@ export type MarkTypes = | 'wordCloud' | 'gauge' | 'density' + | 'heatmap' | MarkComponent; export type ChannelTypes = @@ -373,5 +374,6 @@ export type GaugeMark = BaseMark< >; export type DensityMark = BaseMark<'density', ChannelTypes | 'series'>; +export type HeatmapMark = BaseMark<'heatmap'>; export type CustomMark = BaseMark; diff --git a/src/stdlib/index.ts b/src/stdlib/index.ts index a15f2f6136..a25dd0acaf 100644 --- a/src/stdlib/index.ts +++ b/src/stdlib/index.ts @@ -41,6 +41,7 @@ import { WordCloud as WordCloudGeometry, Density as DensityGeometry, Gauge, + Heatmap, } from '../mark'; import { Category10, Category20 } from '../palette'; import { @@ -112,6 +113,7 @@ import { HollowPath, Density as DensityShape, Shape as CustomShape, + Heatmap as HeatmapShape, } from '../shape'; import { Classic, ClassicDark, Academy } from '../theme'; import { @@ -356,6 +358,7 @@ export function createLibrary(): G2Library { 'mark.tree': TreeGeometry, 'mark.wordCloud': WordCloudGeometry, 'mark.density': DensityGeometry, + 'mark.heatmap': Heatmap, 'palette.category10': Category10, 'palette.category20': Category20, 'scale.linear': Linear, @@ -432,6 +435,7 @@ export function createLibrary(): G2Library { 'shape.path.path': PathShape, 'shape.path.hollow': HollowPath, 'shape.density.density': DensityShape, + 'shape.heatmap.heatmap': HeatmapShape, 'theme.classic': Classic, 'theme.classicDark': ClassicDark, 'theme.academy': Academy, diff --git a/src/utils/lru.ts b/src/utils/lru.ts new file mode 100644 index 0000000000..fc018bf1d6 --- /dev/null +++ b/src/utils/lru.ts @@ -0,0 +1,25 @@ +import flru from 'flru'; + +const cache = flru(3); +/** + * A decorator to return new function with LRU cache. + */ +export function lru( + fn: (...args: T[]) => V, + keyFn: (...args: T[]) => string = (...args) => `${args[0]}`, + maxSize = 16, +) { + const cache = flru(maxSize); + + return (...args) => { + const key = keyFn(...args); + let v = cache.get(key); + + if (cache.has(key)) return cache.get(key); + + v = fn(...args); + cache.set(key, v); + + return v; + }; +}