mirror of
https://github.com/maputnik/editor.git
synced 2025-12-06 06:10:00 +00:00
Compare commits
873 Commits
v1.1.0-bet
...
v1.7.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6da83c4670 | ||
|
|
d26af16003 | ||
|
|
d75b86c927 | ||
|
|
a0cd087ccc | ||
|
|
313b639a5f | ||
|
|
93c45d5340 | ||
|
|
3be6cb5926 | ||
|
|
9d151fdc1f | ||
|
|
44d1a7a6b0 | ||
|
|
0e5676eae0 | ||
|
|
b8739915b2 | ||
|
|
a1dedd1aa6 | ||
|
|
33b4a40c35 | ||
|
|
a624909819 | ||
|
|
d5d387f349 | ||
|
|
c58ae0f895 | ||
|
|
c9e360d675 | ||
|
|
75ece350bd | ||
|
|
45680151ef | ||
|
|
87bae82b17 | ||
|
|
fcad636f85 | ||
|
|
bac8495b3c | ||
|
|
df98cb9c7b | ||
|
|
34c3015b42 | ||
|
|
7d51ea9b25 | ||
|
|
ca7bf9f4a7 | ||
|
|
61ba399e1c | ||
|
|
b5c09a4f17 | ||
|
|
fcfc7ab874 | ||
|
|
a0bc4744a2 | ||
|
|
e6e4c928f3 | ||
|
|
00388e03b8 | ||
|
|
6f83839a4c | ||
|
|
74b47e7e74 | ||
|
|
f70d078ec6 | ||
|
|
1d8131fb85 | ||
|
|
8c82db9162 | ||
|
|
f23f60807a | ||
|
|
8f581956e8 | ||
|
|
87fb0f6a5c | ||
|
|
1c953bc296 | ||
|
|
ce976991d4 | ||
|
|
be7642976b | ||
|
|
a5b226d9f3 | ||
|
|
1b3d8b5b79 | ||
|
|
97a61afc24 | ||
|
|
d1f6bc95db | ||
|
|
10b03c4e00 | ||
|
|
449d8e7665 | ||
|
|
4b8800e8ac | ||
|
|
874c6460f6 | ||
|
|
55cb86f721 | ||
|
|
a30017fd2c | ||
|
|
3b5ba6c59e | ||
|
|
a693f6db4e | ||
|
|
5be7e0c7ec | ||
|
|
7c6b3c0d80 | ||
|
|
e5e03be382 | ||
|
|
0d35106cc8 | ||
|
|
5710edcff7 | ||
|
|
2cc7c63bb1 | ||
|
|
ba9d21c045 | ||
|
|
4ef6ecb7eb | ||
|
|
52e8b21b3d | ||
|
|
c6ba4f66e2 | ||
|
|
5a47a96f09 | ||
|
|
ae878f6000 | ||
|
|
aebfe62a8e | ||
|
|
6be3543616 | ||
|
|
0f6708d9d4 | ||
|
|
0705522a24 | ||
|
|
35098111ac | ||
|
|
39333953d7 | ||
|
|
adea3d0f13 | ||
|
|
d1cb2690fc | ||
|
|
3ffdcc9639 | ||
|
|
793b5d15ad | ||
|
|
cff32696cc | ||
|
|
029eff9317 | ||
|
|
b7d08dfaa6 | ||
|
|
94089836bf | ||
|
|
ff8a8fb749 | ||
|
|
1300951a29 | ||
|
|
3cb1ed9403 | ||
|
|
a5ac1cc93d | ||
|
|
29a0ef0d1c | ||
|
|
26907f7014 | ||
|
|
3ac06c7cb1 | ||
|
|
f268f09ca2 | ||
|
|
f4c18fd91b | ||
|
|
0567b098ec | ||
|
|
dc6006fd6d | ||
|
|
109261ba00 | ||
|
|
b539644b2b | ||
|
|
be36eec93d | ||
|
|
fe5066a2a4 | ||
|
|
642e5c0b29 | ||
|
|
97bdc93a39 | ||
|
|
c770b440c2 | ||
|
|
7559985a2e | ||
|
|
532bbecb47 | ||
|
|
8ed67e98ce | ||
|
|
5792c632f9 | ||
|
|
3e2927e6a4 | ||
|
|
f09cc25a3b | ||
|
|
c5c3e93aff | ||
|
|
cc371d6a70 | ||
|
|
1b17e8fa0a | ||
|
|
bc4706de83 | ||
|
|
0f22eb83d3 | ||
|
|
a8cbe19f09 | ||
|
|
c714e23d79 | ||
|
|
5b3d579f87 | ||
|
|
725b752e35 | ||
|
|
223721a65d | ||
|
|
9b4d924dff | ||
|
|
b31537e063 | ||
|
|
63ed8c1de3 | ||
|
|
7aa0298f7c | ||
|
|
62f3cbe8fb | ||
|
|
30facc885f | ||
|
|
17aa88e3b6 | ||
|
|
5b9af07ebc | ||
|
|
0009c74948 | ||
|
|
8911f83ef3 | ||
|
|
2fafafe0dc | ||
|
|
27e6675d26 | ||
|
|
4269e4573c | ||
|
|
096e2b6aec | ||
|
|
33e04b3527 | ||
|
|
79fa2b3508 | ||
|
|
d5ef412300 | ||
|
|
0726a494be | ||
|
|
926969b921 | ||
|
|
59e070f463 | ||
|
|
2ccd1d227e | ||
|
|
655877f67e | ||
|
|
6c240d53e4 | ||
|
|
f89f8ed4ea | ||
|
|
6123b464de | ||
|
|
49dba02e8f | ||
|
|
fb49a3abe5 | ||
|
|
c88f9ab5dc | ||
|
|
d886b14d09 | ||
|
|
bd1204a7a5 | ||
|
|
9cadda0236 | ||
|
|
90ea6323c1 | ||
|
|
51f2cfac16 | ||
|
|
4dbb423ac2 | ||
|
|
a3ee1cc27e | ||
|
|
fea0798349 | ||
|
|
bd8abffa28 | ||
|
|
a5f3a43cde | ||
|
|
6c5dc7e06b | ||
|
|
b1c8a12e88 | ||
|
|
401c6971f4 | ||
|
|
7e5a5ce077 | ||
|
|
6b245c9894 | ||
|
|
b963fe9619 | ||
|
|
673887d93b | ||
|
|
06898429fd | ||
|
|
0196ba4eb4 | ||
|
|
ef81534a17 | ||
|
|
a958ec943b | ||
|
|
4e3b395b3d | ||
|
|
5e7fd4f93c | ||
|
|
25cad5bb25 | ||
|
|
f9c230414e | ||
|
|
866f8d034a | ||
|
|
be6aa559fb | ||
|
|
a560176d83 | ||
|
|
4644e78fd2 | ||
|
|
237cc16b97 | ||
|
|
dffa54afb0 | ||
|
|
225e5c48e4 | ||
|
|
2e017d252a | ||
|
|
e728e5f7e4 | ||
|
|
f0371b41b1 | ||
|
|
a51442921a | ||
|
|
f39fb34f36 | ||
|
|
566201fb45 | ||
|
|
88841b56e7 | ||
|
|
5aa0b4e7d9 | ||
|
|
f19fc4a8a1 | ||
|
|
cd162309a8 | ||
|
|
aead867e27 | ||
|
|
663f295623 | ||
|
|
c588164190 | ||
|
|
d61d0a5795 | ||
|
|
dddd604f7b | ||
|
|
ea3b9a20c5 | ||
|
|
7415b8af08 | ||
|
|
d06e053d34 | ||
|
|
7075a8b05e | ||
|
|
4cbcf14588 | ||
|
|
ca202d7701 | ||
|
|
8dfc16e7ee | ||
|
|
fbf5cec670 | ||
|
|
14d4383f8a | ||
|
|
58bdd39f9e | ||
|
|
ab9ab7acc7 | ||
|
|
be39fd2ec8 | ||
|
|
3c0185da27 | ||
|
|
b37b7276fb | ||
|
|
c45cf2f0c8 | ||
|
|
1f03fdbb50 | ||
|
|
f3b8c5362a | ||
|
|
c9a5dd01be | ||
|
|
0fa4d40e92 | ||
|
|
8a6e64c8c2 | ||
|
|
72b6dd1ae9 | ||
|
|
ee525631fa | ||
|
|
ee9e055af3 | ||
|
|
b214c6ac7e | ||
|
|
eb75020861 | ||
|
|
a44e757e31 | ||
|
|
9ac908948d | ||
|
|
19e82e5890 | ||
|
|
bf84fd24ee | ||
|
|
affeb7c751 | ||
|
|
9743361e0d | ||
|
|
ab16120af2 | ||
|
|
37e5ba0fff | ||
|
|
0aa0dad7fb | ||
|
|
2910efde6e | ||
|
|
eac7656786 | ||
|
|
be3175beae | ||
|
|
26de95a263 | ||
|
|
d0a47bd122 | ||
|
|
8c760bb810 | ||
|
|
c27deefdef | ||
|
|
392a845460 | ||
|
|
e7622c2080 | ||
|
|
3a558412ba | ||
|
|
95e205943a | ||
|
|
eb8686325c | ||
|
|
1f77e156e6 | ||
|
|
92ee50a4a4 | ||
|
|
ef23f01e67 | ||
|
|
22b6a4a2bf | ||
|
|
201ecac156 | ||
|
|
563a78ed42 | ||
|
|
47acc2640b | ||
|
|
f088788246 | ||
|
|
e219dcd332 | ||
|
|
b8829d9a5c | ||
|
|
2c83c976c6 | ||
|
|
d63782ddf2 | ||
|
|
3eabcbec72 | ||
|
|
00ab303e44 | ||
|
|
38bf12701e | ||
|
|
e4ec1d155a | ||
|
|
361f083687 | ||
|
|
c1a59200e2 | ||
|
|
6e0432ff5e | ||
|
|
1c83de08c1 | ||
|
|
0af828543b | ||
|
|
369cc23a30 | ||
|
|
db56ad8b2e | ||
|
|
7fa17d81ac | ||
|
|
019c6a0086 | ||
|
|
c1bee74b57 | ||
|
|
b794279304 | ||
|
|
935dfa1704 | ||
|
|
bda7a0e659 | ||
|
|
8d1cc340b8 | ||
|
|
338c6b59a8 | ||
|
|
021f8ab400 | ||
|
|
f305db9e3e | ||
|
|
e916b25594 | ||
|
|
5f1e212759 | ||
|
|
2b7db498ef | ||
|
|
e6464790f6 | ||
|
|
13ddf9f754 | ||
|
|
30edb881ed | ||
|
|
b30bbdc248 | ||
|
|
824616f6bd | ||
|
|
109198a524 | ||
|
|
920e4fe630 | ||
|
|
5e143e0a8e | ||
|
|
57f803d52c | ||
|
|
c55d342c7e | ||
|
|
e9065635cd | ||
|
|
6057721249 | ||
|
|
975487d271 | ||
|
|
46b2fd5978 | ||
|
|
f61313449f | ||
|
|
366ad4d7df | ||
|
|
b5cfb44cf0 | ||
|
|
050cc9cea9 | ||
|
|
b2f194eeee | ||
|
|
97b0b8541d | ||
|
|
b5eb74fe20 | ||
|
|
0500172d42 | ||
|
|
0e7bd98485 | ||
|
|
ff0ece5149 | ||
|
|
db9ad86ac2 | ||
|
|
a066710bfb | ||
|
|
52740483b6 | ||
|
|
518a624e20 | ||
|
|
4ba71c8bd5 | ||
|
|
ceeb628784 | ||
|
|
2ec6a90dc3 | ||
|
|
4e37d834ed | ||
|
|
a7922d894d | ||
|
|
eeda3296ab | ||
|
|
acd26e0162 | ||
|
|
fbf828e202 | ||
|
|
af9015f529 | ||
|
|
7a172b2022 | ||
|
|
a609dc4029 | ||
|
|
92bfee4bcc | ||
|
|
559d4618d1 | ||
|
|
5c391ee287 | ||
|
|
db74cfeb2a | ||
|
|
726b825e4c | ||
|
|
84d56b2606 | ||
|
|
e9a8b094a2 | ||
|
|
924b14621a | ||
|
|
b072e3a98c | ||
|
|
827bd5fa24 | ||
|
|
9e0410afe6 | ||
|
|
ef08a9347e | ||
|
|
9b732540a6 | ||
|
|
24c52074b8 | ||
|
|
cb6c6e0d9f | ||
|
|
884dc6fa49 | ||
|
|
efe42021f1 | ||
|
|
470277c253 | ||
|
|
c1cab38c7a | ||
|
|
1cf36ddb08 | ||
|
|
1fec89b69e | ||
|
|
911549aca3 | ||
|
|
41329ec2f8 | ||
|
|
15cdfbc980 | ||
|
|
5053058c32 | ||
|
|
8a8cfad303 | ||
|
|
cc3c17078d | ||
|
|
47965d5f57 | ||
|
|
c947d9e3ed | ||
|
|
8318180e96 | ||
|
|
87daf6fb76 | ||
|
|
5d254ac2ff | ||
|
|
482f322d9f | ||
|
|
b1d097a40f | ||
|
|
da456b08fe | ||
|
|
2776ac3ce0 | ||
|
|
eb2fc4c715 | ||
|
|
d2ffc3a0b1 | ||
|
|
40a9978f31 | ||
|
|
22688933b3 | ||
|
|
7cf01f0c12 | ||
|
|
1e87765f95 | ||
|
|
cc8fe4e02e | ||
|
|
979fc98e70 | ||
|
|
c3c0c35d8a | ||
|
|
f9913cad63 | ||
|
|
0ed64f94e8 | ||
|
|
1deecd4e2a | ||
|
|
e0e9201b46 | ||
|
|
362c7b437e | ||
|
|
efd0b547e9 | ||
|
|
afbaaa66bc | ||
|
|
de8c687dfb | ||
|
|
1bb079f078 | ||
|
|
b35322522f | ||
|
|
6a605571e0 | ||
|
|
c4ec77c911 | ||
|
|
f7643cee7e | ||
|
|
cd95202dcc | ||
|
|
facba3998b | ||
|
|
1f9cc2ce33 | ||
|
|
ad505378ab | ||
|
|
fb7b30c81d | ||
|
|
cb3f93c67d | ||
|
|
76cb5a6b7c | ||
|
|
990e47cb24 | ||
|
|
8be7465428 | ||
|
|
e2b7a6a517 | ||
|
|
284e00b665 | ||
|
|
1fbfa8428b | ||
|
|
b13b89c7ce | ||
|
|
3fa31e2a2e | ||
|
|
0e7d2d8ff5 | ||
|
|
2def3820dd | ||
|
|
e1a489f318 | ||
|
|
760839eb83 | ||
|
|
7136d2dea3 | ||
|
|
21f4d26b50 | ||
|
|
627ea268c9 | ||
|
|
65f77db4d6 | ||
|
|
03b4f1eb8d | ||
|
|
82d1fc0a8b | ||
|
|
3b16de5df4 | ||
|
|
94d4070653 | ||
|
|
d856a1cd8e | ||
|
|
7c1d0f7bee | ||
|
|
d02036321f | ||
|
|
1edadcc6bb | ||
|
|
aa92091d2d | ||
|
|
14e0385575 | ||
|
|
6cf861d44e | ||
|
|
1375240bfa | ||
|
|
8f391d7d52 | ||
|
|
84654e81af | ||
|
|
7ff0524bb7 | ||
|
|
06c3c92fd6 | ||
|
|
4c2941e9b6 | ||
|
|
ed7a25646e | ||
|
|
8c821176cf | ||
|
|
98ded98583 | ||
|
|
10ae69e41f | ||
|
|
04531b4305 | ||
|
|
7ab4b2481c | ||
|
|
6fa88e6869 | ||
|
|
5b90c31645 | ||
|
|
5eba11faee | ||
|
|
54b4fc473c | ||
|
|
fe8595cdc9 | ||
|
|
2565a89474 | ||
|
|
92b970377d | ||
|
|
dd3a550ec3 | ||
|
|
7d5d9e2d82 | ||
|
|
9daa71befc | ||
|
|
f10a2d28df | ||
|
|
d52c6c70bb | ||
|
|
6e2f46a0da | ||
|
|
2d6f91d0cd | ||
|
|
f409079d93 | ||
|
|
7004259867 | ||
|
|
f3128cb6d2 | ||
|
|
1dfd4d8d48 | ||
|
|
2f30eb6cbe | ||
|
|
cf45c04069 | ||
|
|
05d149bcfa | ||
|
|
3acbc3291c | ||
|
|
24aa2fd5fa | ||
|
|
cc7d7a56f5 | ||
|
|
da17646b8d | ||
|
|
f9233a1e31 | ||
|
|
c94f536e5a | ||
|
|
b712e7f184 | ||
|
|
b8ab802de5 | ||
|
|
a74eb2989c | ||
|
|
3a0fc6eeac | ||
|
|
0f0684e701 | ||
|
|
e9b5bfb572 | ||
|
|
b456b59c44 | ||
|
|
59ad91fdf8 | ||
|
|
e18d304313 | ||
|
|
5de5281b49 | ||
|
|
fe0df2a4ef | ||
|
|
deec7894dd | ||
|
|
c9a0c0400e | ||
|
|
419e62f69b | ||
|
|
9ffbe3a7a2 | ||
|
|
c8e548e3be | ||
|
|
b9160bd333 | ||
|
|
8ad8b4cdea | ||
|
|
c51c40a20e | ||
|
|
2ccb1a8e0a | ||
|
|
008bb75c04 | ||
|
|
a51fdb8435 | ||
|
|
cdd5d27908 | ||
|
|
82d3c934c8 | ||
|
|
f2f0270936 | ||
|
|
00f646d489 | ||
|
|
90a02df45c | ||
|
|
ad40a15a77 | ||
|
|
d6809cb504 | ||
|
|
ff8d3055b4 | ||
|
|
1f81449e3c | ||
|
|
c59e0cb046 | ||
|
|
2d1675c181 | ||
|
|
48ebca6236 | ||
|
|
bce8e8b807 | ||
|
|
1f4dfa7603 | ||
|
|
c23af0063d | ||
|
|
9f6250c489 | ||
|
|
cf6c6f1c17 | ||
|
|
c6163b6ba2 | ||
|
|
7121a680b4 | ||
|
|
cf391031f0 | ||
|
|
9cac5305cd | ||
|
|
b0adb8cd3d | ||
|
|
3d2a1d5d19 | ||
|
|
3c93c41de1 | ||
|
|
4baed5d8ab | ||
|
|
f17b02b1fe | ||
|
|
3c72d07a88 | ||
|
|
7495c0dfcf | ||
|
|
b0c877d4ae | ||
|
|
e1fd0f8014 | ||
|
|
cb2198b661 | ||
|
|
68beeeb599 | ||
|
|
218ce148d5 | ||
|
|
d0cafb06ee | ||
|
|
5671a58704 | ||
|
|
9cf74ca405 | ||
|
|
1c6e3648eb | ||
|
|
b3a4628a79 | ||
|
|
941cc37c87 | ||
|
|
906d7ac3d5 | ||
|
|
588b18d10e | ||
|
|
90024c5ec7 | ||
|
|
889005de6c | ||
|
|
843d3df8bc | ||
|
|
825b9044b9 | ||
|
|
92a1be83b6 | ||
|
|
36e35eb208 | ||
|
|
2fcdb47fe5 | ||
|
|
012e4b670e | ||
|
|
492cc244d8 | ||
|
|
d17d6b43c0 | ||
|
|
1bf10cd6d6 | ||
|
|
b0cd9140be | ||
|
|
cc6196969f | ||
|
|
802a7eb1be | ||
|
|
a666f86be0 | ||
|
|
44fad76d45 | ||
|
|
c8d23a534e | ||
|
|
cf3650c8cd | ||
|
|
1a8349f821 | ||
|
|
855771a6b6 | ||
|
|
b711168e44 | ||
|
|
4134919dde | ||
|
|
158153e366 | ||
|
|
6b890d162a | ||
|
|
1525807d06 | ||
|
|
a356bfd601 | ||
|
|
e6d2a6d5ff | ||
|
|
c8a004422f | ||
|
|
df457fc7bf | ||
|
|
6e03f1f077 | ||
|
|
4c13350c14 | ||
|
|
63a2495c68 | ||
|
|
f9de73e18a | ||
|
|
e6e2be61f0 | ||
|
|
060f7aa42c | ||
|
|
8b0ae178b8 | ||
|
|
6b94e9b78b | ||
|
|
b171bf3127 | ||
|
|
0c6a179cec | ||
|
|
aa50785c12 | ||
|
|
252403b1e3 | ||
|
|
bc1d0de057 | ||
|
|
4a0b9fd0de | ||
|
|
e8777e1857 | ||
|
|
94a2a16330 | ||
|
|
004d135d93 | ||
|
|
0973dcee8a | ||
|
|
c908f7dcd0 | ||
|
|
b7fd889fcd | ||
|
|
35600c253d | ||
|
|
673465af77 | ||
|
|
cc5d0dc4fe | ||
|
|
e6da977c48 | ||
|
|
e4aa016713 | ||
|
|
8b67499a64 | ||
|
|
bcdc7c6811 | ||
|
|
8f07a79a49 | ||
|
|
cdcf16196c | ||
|
|
a0ed6a379b | ||
|
|
7ffb44f604 | ||
|
|
225d0388ce | ||
|
|
0468db8cc2 | ||
|
|
695f612110 | ||
|
|
9c07852b87 | ||
|
|
9ef198fb86 | ||
|
|
fd34e31462 | ||
|
|
8eb49427fd | ||
|
|
ebafb3c3dd | ||
|
|
09c6154949 | ||
|
|
53c8661cd3 | ||
|
|
3d5eec897e | ||
|
|
3763ec3737 | ||
|
|
f1216795d2 | ||
|
|
0ac70df00f | ||
|
|
7d0a985f1d | ||
|
|
c5ff67b6e0 | ||
|
|
db6b9ac176 | ||
|
|
77475af3c6 | ||
|
|
805133d10c | ||
|
|
fff8fb72c5 | ||
|
|
e02b18cea3 | ||
|
|
b8e9307ce2 | ||
|
|
00b22eb902 | ||
|
|
be954143c3 | ||
|
|
b314642586 | ||
|
|
b5fc315b37 | ||
|
|
26ff9f63bb | ||
|
|
7e5fb4d42f | ||
|
|
762bb786be | ||
|
|
cec87765fc | ||
|
|
b966fae926 | ||
|
|
f1ddf4e57e | ||
|
|
64e65dc7d3 | ||
|
|
1e07a88aed | ||
|
|
6e49cc65a9 | ||
|
|
06d579118a | ||
|
|
f0e4b5b930 | ||
|
|
088127a9a5 | ||
|
|
a4b4d077fa | ||
|
|
bc2ec4d0b7 | ||
|
|
e4de101553 | ||
|
|
6207416b32 | ||
|
|
f0202241f4 | ||
|
|
0e8c94af1e | ||
|
|
922ee616ec | ||
|
|
98c235bc21 | ||
|
|
70f1f9ffac | ||
|
|
409f81f0d8 | ||
|
|
1aa90bef37 | ||
|
|
c5ea9494df | ||
|
|
9a34db7008 | ||
|
|
988b7fca0f | ||
|
|
bdc6489db4 | ||
|
|
49b096b601 | ||
|
|
31d83f6a26 | ||
|
|
03e52b7a72 | ||
|
|
551e950c39 | ||
|
|
a7620f83a6 | ||
|
|
0384181ee1 | ||
|
|
fd59f42819 | ||
|
|
cc51774259 | ||
|
|
5a19245ee0 | ||
|
|
45f45b7547 | ||
|
|
530bfaf3b3 | ||
|
|
6ea70ab9cf | ||
|
|
a0e2d68dae | ||
|
|
1447e8bfb5 | ||
|
|
c0480a50ea | ||
|
|
09ba2be416 | ||
|
|
115ce3305d | ||
|
|
960b2022ed | ||
|
|
252b442ca9 | ||
|
|
03b9ddda9c | ||
|
|
968d7d7fda | ||
|
|
b211f1cd12 | ||
|
|
870d4349f4 | ||
|
|
d88bc59720 | ||
|
|
7c00775515 | ||
|
|
4b5536b282 | ||
|
|
fb84cfee1c | ||
|
|
9132262106 | ||
|
|
5de9e708e9 | ||
|
|
4df63c7287 | ||
|
|
a88ca031d0 | ||
|
|
452706f35c | ||
|
|
8b0aa194cf | ||
|
|
b9aa7e9206 | ||
|
|
e35f106482 | ||
|
|
b7a97cf8ee | ||
|
|
85a28999fb | ||
|
|
9208115981 | ||
|
|
afbdaecd0a | ||
|
|
558f3d649d | ||
|
|
417511d577 | ||
|
|
df350534ce | ||
|
|
7167235146 | ||
|
|
7a7f2eb7de | ||
|
|
cd28a53f6a | ||
|
|
1fe31ac0ec | ||
|
|
ffce8e3ba5 | ||
|
|
a28a417ebc | ||
|
|
6cdb56d13f | ||
|
|
0516e587b4 | ||
|
|
5b4063105b | ||
|
|
d9a5548762 | ||
|
|
cae6cffb7b | ||
|
|
ede782abed | ||
|
|
00afbad7ac | ||
|
|
edd09ef585 | ||
|
|
1e09066779 | ||
|
|
32edb48e16 | ||
|
|
b116eef147 | ||
|
|
74d1cd2d01 | ||
|
|
fd48d82e42 | ||
|
|
480d54c2d8 | ||
|
|
ab9c39b862 | ||
|
|
dd122d1bac | ||
|
|
f9f5e8f925 | ||
|
|
aa2f4a091c | ||
|
|
13fc699d4a | ||
|
|
f5e8d473ad | ||
|
|
35353d75f5 | ||
|
|
0f103c3c00 | ||
|
|
019428a241 | ||
|
|
6200edea25 | ||
|
|
fc7395df96 | ||
|
|
272f662a34 | ||
|
|
d59d9cde95 | ||
|
|
c71fbcf436 | ||
|
|
54c79445db | ||
|
|
a82ba26f86 | ||
|
|
28af87391d | ||
|
|
0aabd33538 | ||
|
|
bd9076c4ff | ||
|
|
1aed761893 | ||
|
|
a2a6f6dcab | ||
|
|
db5dd0f6ee | ||
|
|
42c3dcf258 | ||
|
|
51a115d65a | ||
|
|
fc0fbd6a37 | ||
|
|
d80d76724c | ||
|
|
77da0a6d30 | ||
|
|
79b251d8b9 | ||
|
|
4f19f6a08c | ||
|
|
d2a6eab1e6 | ||
|
|
c7cf051502 | ||
|
|
6e21503e6b | ||
|
|
78d71a4e7e | ||
|
|
b8f32d46cf | ||
|
|
443782decf | ||
|
|
54e79e5eb8 | ||
|
|
221cd4ffd2 | ||
|
|
354b2fb3cb | ||
|
|
7cb2c36ac9 | ||
|
|
11d73595fc | ||
|
|
c241a6e280 | ||
|
|
198ff143f6 | ||
|
|
7b8b797f9c | ||
|
|
a41b25eea7 | ||
|
|
06eac68f9d | ||
|
|
8abf84ebc0 | ||
|
|
e9aa1f6dd6 | ||
|
|
8e7b838bf7 | ||
|
|
32db3c3c9b | ||
|
|
502586e5d5 | ||
|
|
d92d599d8a | ||
|
|
3487056c7d | ||
|
|
dbcfb08c15 | ||
|
|
e96141090e | ||
|
|
5bd25fc2ed | ||
|
|
334932b298 | ||
|
|
661006d7fb | ||
|
|
c917249517 | ||
|
|
d0ca732fe7 | ||
|
|
52821cd1df | ||
|
|
328e0b8ff7 | ||
|
|
f0147cc89a | ||
|
|
78a7f152e7 | ||
|
|
e936dd16bf | ||
|
|
3d4579288c | ||
|
|
b60df8b074 | ||
|
|
c4b92fa0a9 | ||
|
|
9808d44c71 | ||
|
|
1bdd135386 | ||
|
|
740a75f2e6 | ||
|
|
b62533fa3e | ||
|
|
044349e65f | ||
|
|
e8b0bd4d0a | ||
|
|
45bdf53a41 | ||
|
|
00e94212bd | ||
|
|
1805aee7ba | ||
|
|
8ba2123a26 | ||
|
|
687c08527d | ||
|
|
f0744f024d | ||
|
|
9e82599464 | ||
|
|
7a60df370e | ||
|
|
aee4a041fe | ||
|
|
6fa06e5483 | ||
|
|
15962481ee | ||
|
|
6bf695cd4b | ||
|
|
7ecbc14c39 | ||
|
|
fb0e531f4a | ||
|
|
bd44e6d071 | ||
|
|
3ae37f1c46 | ||
|
|
8c7a1f7075 | ||
|
|
3e97d8a5f1 | ||
|
|
6138257a89 | ||
|
|
0bd62985b9 | ||
|
|
a346d757fd | ||
|
|
84f3970730 | ||
|
|
050e22918a | ||
|
|
f205776695 | ||
|
|
4d427bcbc3 | ||
|
|
0b4910e3c3 | ||
|
|
11a59debdf | ||
|
|
dbe2c2637e | ||
|
|
d6ce13c356 | ||
|
|
6d094a8b3e | ||
|
|
4d0456fd68 | ||
|
|
ad83f940a7 | ||
|
|
edc7e02f58 | ||
|
|
7dfc5029a3 | ||
|
|
8e02722b52 | ||
|
|
984581e01a | ||
|
|
1de7ba7e86 | ||
|
|
a3fa86f7ee | ||
|
|
a589f89c4c | ||
|
|
3b599aed4c | ||
|
|
6953db74c6 | ||
|
|
1ad473a539 | ||
|
|
fafda9ec92 | ||
|
|
11b85bf565 | ||
|
|
6ecc6670dc | ||
|
|
553f0fe23e | ||
|
|
77ddf67201 | ||
|
|
a092bc2689 | ||
|
|
38e0786463 | ||
|
|
180b17d315 | ||
|
|
8acbd784a0 | ||
|
|
07efe1e1b8 | ||
|
|
7ea53cc3a1 | ||
|
|
de21eea21b | ||
|
|
8f8ed6dff3 | ||
|
|
8915bbfeb4 | ||
|
|
df3a42acce | ||
|
|
2a7ef82d23 | ||
|
|
95168f22e3 | ||
|
|
4360753263 | ||
|
|
ad491cb465 | ||
|
|
e5bed80c96 | ||
|
|
9bf3046d4c | ||
|
|
da8dc0f7a6 | ||
|
|
b66a4afd28 | ||
|
|
a94c53534c | ||
|
|
6b22c9130f | ||
|
|
7d5927bbc8 | ||
|
|
240d02a124 | ||
|
|
92ef1c4cbb | ||
|
|
5ce57d0803 | ||
|
|
1c134d757c | ||
|
|
32d808b230 | ||
|
|
ee3def492a | ||
|
|
41bd91fcd2 | ||
|
|
02c8542848 | ||
|
|
844abd38ce | ||
|
|
d9b6f28bb5 | ||
|
|
ed85b838ec | ||
|
|
f82b138a3d | ||
|
|
89c38991b9 | ||
|
|
0e4c06cc3e | ||
|
|
7e510a2582 | ||
|
|
f3cb9c4fdd | ||
|
|
f0f6130272 | ||
|
|
0ebb299fd0 | ||
|
|
9d96525f12 | ||
|
|
fc6f9251f7 | ||
|
|
53cb317155 | ||
|
|
4215b5808f | ||
|
|
2d2f9744e2 | ||
|
|
d0b835ee52 | ||
|
|
1798305f9c | ||
|
|
4b0768d0a6 | ||
|
|
2e79a8ff4c | ||
|
|
e64ca3eb93 | ||
|
|
094c4747d3 | ||
|
|
62f0843283 | ||
|
|
8062e304b7 | ||
|
|
18e7ead78a | ||
|
|
3cab1dc49f | ||
|
|
f8dcbb8fb7 | ||
|
|
c82f38c103 | ||
|
|
fe0e7af033 | ||
|
|
ac51902435 | ||
|
|
e0ff342702 | ||
|
|
cb4f5ea963 | ||
|
|
a822430e1d | ||
|
|
dc40ce7d9e | ||
|
|
383a119127 | ||
|
|
3f492e6208 | ||
|
|
0cec0cf595 | ||
|
|
bc19aea438 | ||
|
|
211850c813 | ||
|
|
c1312fb288 | ||
|
|
0c2934c489 | ||
|
|
ad34147f28 | ||
|
|
1eb6c28617 | ||
|
|
2e8a188bce | ||
|
|
a773958403 | ||
|
|
942b2240a7 |
20
.babelrc
20
.babelrc
@@ -1,4 +1,20 @@
|
||||
{
|
||||
"presets": ["env", "react"],
|
||||
"plugins": ["transform-object-rest-spread", "transform-class-properties"]
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-react"
|
||||
],
|
||||
"plugins": [
|
||||
"static-fs",
|
||||
"react-hot-loader/babel",
|
||||
"@babel/plugin-proposal-class-properties"
|
||||
],
|
||||
"env": {
|
||||
"test": {
|
||||
"plugins": [
|
||||
["istanbul", {
|
||||
"exclude": ["node_modules/**", "test/**"]
|
||||
}]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
103
.circleci/config.yml
Normal file
103
.circleci/config.yml
Normal file
@@ -0,0 +1,103 @@
|
||||
version: 2
|
||||
templates:
|
||||
# Test the build **only** no webdriver
|
||||
build-steps: &build-steps
|
||||
- checkout
|
||||
- run:
|
||||
name: "Create artifacts directory"
|
||||
command: mkdir /tmp/artifacts
|
||||
- restore_cache:
|
||||
key: v1-dependencies-{{ arch }}-{{ checksum "package.json" }}
|
||||
|
||||
- run: npm install
|
||||
|
||||
- save_cache:
|
||||
paths:
|
||||
- node_modules
|
||||
key: v1-dependencies-{{ arch }}-{{ checksum "package.json" }}
|
||||
|
||||
- run: mkdir -p /tmp/artifacts/logs
|
||||
- run: npm run build
|
||||
- run: npm run lint-js
|
||||
- run: npm run lint-css
|
||||
- store_artifacts:
|
||||
path: /tmp/artifacts
|
||||
destination: /artifacts
|
||||
# Test in webdriver
|
||||
wdio-steps: &wdio-steps
|
||||
- checkout
|
||||
- run:
|
||||
name: "Create artifacts directory"
|
||||
command: mkdir /tmp/artifacts
|
||||
- restore_cache:
|
||||
key: v1-dependencies-{{ arch }}-{{ checksum "package.json" }}
|
||||
|
||||
- run: npm install
|
||||
|
||||
- save_cache:
|
||||
paths:
|
||||
- node_modules
|
||||
key: v1-dependencies-{{ arch }}-{{ checksum "package.json" }}
|
||||
|
||||
- run: mkdir -p /tmp/artifacts/logs
|
||||
- run: npm run build
|
||||
- run: npm run profiling-build
|
||||
- run: npm run lint-js
|
||||
- run: npm run lint-css
|
||||
- run: DOCKER_HOST=localhost npm test
|
||||
- run: ./node_modules/.bin/istanbul report --include /tmp/artifacts/coverage/coverage.json --dir /tmp/artifacts/coverage html lcov
|
||||
- store_artifacts:
|
||||
path: /tmp/artifacts
|
||||
destination: /artifacts
|
||||
jobs:
|
||||
build-linux-node-v10:
|
||||
docker:
|
||||
- image: node:10
|
||||
- image: selenium/standalone-chrome:3.141.59
|
||||
working_directory: ~/repo-linux-node-v10
|
||||
steps: *wdio-steps
|
||||
build-linux-node-v12:
|
||||
docker:
|
||||
- image: node:12
|
||||
working_directory: ~/repo-linux-node-v12
|
||||
steps: *build-steps
|
||||
build-linux-node-v13:
|
||||
docker:
|
||||
- image: node:13
|
||||
working_directory: ~/repo-linux-node-v13
|
||||
steps: *build-steps
|
||||
build-osx-node-v10:
|
||||
macos:
|
||||
xcode: "9.0"
|
||||
dependencies:
|
||||
override:
|
||||
- brew install node@10
|
||||
working_directory: ~/repo-osx-node-v10
|
||||
steps: *build-steps
|
||||
build-osx-node-v12:
|
||||
macos:
|
||||
xcode: "9.0"
|
||||
dependencies:
|
||||
override:
|
||||
- brew install node@12
|
||||
working_directory: ~/repo-osx-node-v12
|
||||
steps: *build-steps
|
||||
build-osx-node-v13:
|
||||
macos:
|
||||
xcode: "9.0"
|
||||
dependencies:
|
||||
override:
|
||||
- brew install node@13
|
||||
working_directory: ~/repo-osx-node-v13
|
||||
steps: *build-steps
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
build:
|
||||
jobs:
|
||||
- build-linux-node-v10
|
||||
- build-linux-node-v12
|
||||
- build-linux-node-v13
|
||||
- build-osx-node-v10
|
||||
- build-osx-node-v12
|
||||
- build-osx-node-v13
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
custom: "https://maputnik.github.io/donate"
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,3 +30,6 @@ node_modules
|
||||
|
||||
# Ignore build files
|
||||
public
|
||||
/errorShots
|
||||
/old
|
||||
/build
|
||||
|
||||
15
.topissuesrc
Normal file
15
.topissuesrc
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"labels": {
|
||||
"bug": 5,
|
||||
"maintenance": 3,
|
||||
"mentioned in the 1st survey": 2
|
||||
},
|
||||
"reactions": {
|
||||
"+1": 2,
|
||||
"-1": -1,
|
||||
"laugh": 1,
|
||||
"hooray": 2,
|
||||
"confused": 1,
|
||||
"heart": 2
|
||||
}
|
||||
}
|
||||
41
.travis.yml
41
.travis.yml
@@ -1,41 +0,0 @@
|
||||
language: node_js
|
||||
addons:
|
||||
firefox: latest
|
||||
matrix:
|
||||
include:
|
||||
- os: linux
|
||||
node_js: "6"
|
||||
- os: linux
|
||||
env: CXX=g++-4.8
|
||||
node_js: "7"
|
||||
- os: linux
|
||||
node_js: "8"
|
||||
- os: linux
|
||||
env: CXX=g++-4.8
|
||||
node_js: "9"
|
||||
- os: osx
|
||||
node_js: "6"
|
||||
- os: osx
|
||||
node_js: "7"
|
||||
- os: osx
|
||||
node_js: "8"
|
||||
- os: osx
|
||||
node_js: "9"
|
||||
before_install:
|
||||
- export CHROME_BIN=chromium-browser
|
||||
- export DISPLAY=:99.0
|
||||
- if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sh -e /etc/init.d/xvfb start; fi
|
||||
install:
|
||||
- npm install
|
||||
script:
|
||||
- mkdir public
|
||||
- node --stack_size=100000 $(which npm) run build
|
||||
- npm run lint
|
||||
- npm run lint-styles
|
||||
- npm run test
|
||||
addons:
|
||||
apt:
|
||||
sources:
|
||||
- ubuntu-toolchain-r-test
|
||||
packages:
|
||||
- g++-4.8
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,4 +1,9 @@
|
||||
FROM nodesource/xenial:6.1.0
|
||||
FROM node:10-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
python \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
EXPOSE 8888
|
||||
|
||||
@@ -9,7 +14,8 @@ COPY . ${HOME}/
|
||||
|
||||
WORKDIR ${HOME}
|
||||
|
||||
RUN npm install -d --dev
|
||||
RUN npm install -d
|
||||
RUN npm run build
|
||||
|
||||
CMD npm run start -- --host 0.0.0.0
|
||||
WORKDIR ${HOME}/build/build
|
||||
CMD python -m SimpleHTTPServer 8888
|
||||
|
||||
76
README.md
76
README.md
@@ -1,26 +1,32 @@
|
||||
# Maputnik
|
||||
|
||||
[][travis]
|
||||
[][circleci]
|
||||
[][appveyor]
|
||||
[][dm-prod]
|
||||
[][dm-dev]
|
||||
[][license]
|
||||
|
||||
[travis]: https://travis-ci.org/maputnik/editor
|
||||
[circleci]: https://circleci.com/gh/maputnik/editor/tree/master
|
||||
[appveyor]: https://ci.appveyor.com/project/lukasmartinelli/editor
|
||||
[dm-prod]: https://david-dm.org/maputnik/editor
|
||||
[dm-dev]: https://david-dm.org/maputnik/editor#info=devDependencies
|
||||
[dm-dev]: https://david-dm.org/maputnik/editor?type=dev
|
||||
[license]: https://tldrlegal.com/license/mit-license
|
||||
|
||||
<img width="200" align="right" alt="Maputnik" src="src/img/maputnik.png" />
|
||||
<img width="200" align="right" alt="Maputnik" src="https://cdn.jsdelivr.net/gh/maputnik/editor@1.5.0/src/img/maputnik.png" />
|
||||
|
||||
A free and open visual editor for the [Mapbox GL styles](https://www.mapbox.com/mapbox-gl-style-spec/)
|
||||
targeted at developers and map designers.
|
||||
|
||||
- :link: Design your maps online at **<https://maputnik.github.io/editor/>** (all in local storage)
|
||||
- :link: Try out the v1.7.0-beta release at: https://maputnik.github.io/releases/v1.7.0-beta/
|
||||
- :link: Use the [Maputnik CLI](https://github.com/maputnik/editor/wiki/Maputnik-CLI) for local style development
|
||||
|
||||
Mapbox has built one of the best and most amazing OSS ecosystems. A key component to ensure its longevity and independance is an OSS map designer.
|
||||
Mapbox has built one of the best and most amazing OSS ecosystems. A key component to ensure its longevity and independence is an OSS map designer.
|
||||
|
||||
|
||||
## Donations
|
||||
If you or your organisation has seen value from Maputnik, please consider donating at <https://maputnik.github.io/donate>
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -35,10 +41,7 @@ The documentation can be found in the [Wiki](https://github.com/maputnik/editor/
|
||||
|
||||
Maputnik is written in ES6 and is using [React](https://github.com/facebook/react) and [Mapbox GL JS](https://www.mapbox.com/mapbox-gl-js/api/).
|
||||
|
||||
We ensure building and developing Maputnik works with
|
||||
|
||||
- Linux, OSX and Windows
|
||||
- Node >4
|
||||
We ensure building and developing Maputnik works with the [current active LTS Node.js version and above](https://github.com/nodejs/Release#release-schedule).
|
||||
|
||||
Install the deps, start the dev server and open the web browser on `http://localhost:8888/`.
|
||||
|
||||
@@ -49,12 +52,18 @@ npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
The build process will watch for changes to the filesystem, rebuild and autoreload the editor. However note this from the webpack-dev-server docs
|
||||
If you want Maputnik to be accessible externally use the [`--host` option](https://webpack.js.org/configuration/dev-server/#devserverhost):
|
||||
|
||||
> webpack uses the file system to get notified of file changes. In some cases this does not work. For example, when using Network File System (NFS). Vagrant also has a lot of problems with this.
|
||||
Snippet from <https://webpack.js.org/configuration/dev-server/#devserver-watchoptions->
|
||||
```bash
|
||||
# start externally accessible dev server
|
||||
npm start -- --host 0.0.0.0
|
||||
```
|
||||
|
||||
To enable polling add `export WEBPACK_DEV_SERVER_POLLING=1` to your enviroment.
|
||||
The build process will watch for changes to the filesystem, rebuild and autoreload the editor. However note this from the [webpack-dev-server docs](https://webpack.js.org/configuration/dev-server/):
|
||||
|
||||
> webpack uses the file system to get notified of file changes. In some cases this does not work. For example, when using Network File System (NFS). Vagrant also has a lot of problems with this. ([snippet source](https://webpack.js.org/configuration/dev-server/#devserverwatchoptions-))
|
||||
|
||||
To enable polling add `export WEBPACK_DEV_SERVER_POLLING=1` to your environment.
|
||||
|
||||
```
|
||||
npm run build
|
||||
@@ -68,6 +77,33 @@ npm run lint
|
||||
npm run lint-styles
|
||||
```
|
||||
|
||||
|
||||
## Tests
|
||||
For testing we use [webdriverio](http://webdriver.io) and [selenium-standalone](https://github.com/vvo/selenium-standalone)
|
||||
|
||||
[selenium-standalone](https://github.com/vvo/selenium-standalone) starts a server that will launch browsers on your local machine. We use chrome so you **must** have chrome installed on your machine.
|
||||
|
||||
Now open a terminal and run the following. This will install the drivers on your local machine
|
||||
|
||||
```
|
||||
./node_modules/.bin/selenium-standalone install
|
||||
```
|
||||
|
||||
Now start the standalone server
|
||||
|
||||
```
|
||||
./node_modules/.bin/selenium-standalone start
|
||||
```
|
||||
|
||||
Then open another terminal and run
|
||||
|
||||
```
|
||||
npm test
|
||||
```
|
||||
|
||||
After some time you should see a browser launch which will be automated by the test runner.
|
||||
|
||||
|
||||
## Related Projects
|
||||
|
||||
- [maputnik-dev-server](https://github.com/nycplanning/labs-maputnik-dev-server) - An express.js server that allows for quickly loading the style from any mapboxGL map into mapuntnik.
|
||||
@@ -83,13 +119,13 @@ Thanks to the supporters of the **[Kickstarter campaign](https://www.kickstarter
|
||||
- [Terranodo](http://terranodo.io/)
|
||||
|
||||
<a href="https://getwemap.com/">
|
||||
<img width="33%" alt="Wemap" style="display:inline" src="media/sponsors/wemap.jpg" />
|
||||
<img width="33%" alt="Wemap" style="display:inline" src="https://cdn.jsdelivr.net/gh/maputnik/editor@1.5.0/media/sponsors/wemap.jpg" />
|
||||
</a>
|
||||
<a href="http://terranodo.io/">
|
||||
<img width="33%" alt="Terranodo" style="display:inline" src="media/sponsors/terranodo.png" />
|
||||
<img width="33%" alt="Terranodo" style="display:inline" src="https://cdn.jsdelivr.net/gh/maputnik/editor@1.5.0/media/sponsors/terranodo.png" />
|
||||
</a>
|
||||
<a href="https://www.orbiconinformatik.dk/">
|
||||
<img width="32%" alt="Terranodo" style="display:inline" src="media/sponsors/orbicon_informatik.png" />
|
||||
<img width="32%" alt="Terranodo" style="display:inline" src="https://cdn.jsdelivr.net/gh/maputnik/editor@1.5.0/media/sponsors/orbicon_informatik.png" />
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
@@ -101,13 +137,13 @@ Thanks to the supporters of the **[Kickstarter campaign](https://www.kickstarter
|
||||
- [Dreipol](https://www.dreipol.ch/)
|
||||
|
||||
<a href="https://www.klokantech.com/">
|
||||
<img width="18%" alt="Klokan Technologies" style="display:inline-block" src="media/sponsors/klokantech.png" />
|
||||
<img width="18%" alt="Klokan Technologies" style="display:inline-block" src="https://cdn.jsdelivr.net/gh/maputnik/editor@1.5.0/media/sponsors/klokantech.png" />
|
||||
</a>
|
||||
<a href="http://www.geofabrik.de/">
|
||||
<img width="18%" alt="Geofabrik" style="display:inline-block" src="media/sponsors/geofabrik.png" />
|
||||
<img width="18%" alt="Geofabrik" style="display:inline-block" src="https://cdn.jsdelivr.net/gh/maputnik/editor@1.5.0/media/sponsors/geofabrik.png" />
|
||||
</a>
|
||||
<a href="https://www.dreipol.ch/">
|
||||
<img width="18%" alt="Dreipol" style="display:inline-block" src="media/sponsors/dreipol.png" />
|
||||
<img width="18%" alt="Dreipol" style="display:inline-block" src="https://cdn.jsdelivr.net/gh/maputnik/editor@1.5.0/media/sponsors/dreipol.png" />
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
@@ -130,6 +166,6 @@ Sina Martinelli, Nicholas Doiron, Neil Cawse, Urs42, Benedikt Groß, Manuel Roth
|
||||
|
||||
Maputnik is [licensed under MIT](LICENSE) and is Copyright (c) Lukas Martinelli and contributors.
|
||||
|
||||
**Disclaimer** This project is not affiliated with Mapbox or Mapbox Studio. It is a independent style editor for the
|
||||
**Disclaimer** This project is not affiliated with Mapbox or Mapbox Studio. It is an independent style editor for the
|
||||
open source technology in the Mapbox GL ecosystem.
|
||||
As contributor please take extra care of not violating any Mapbox trademarks. Do not get inspired by Mapbox Studio and make your own decisions for a good style editor.
|
||||
|
||||
23
appveyor.yml
23
appveyor.yml
@@ -1,19 +1,26 @@
|
||||
image: Visual Studio 2019
|
||||
environment:
|
||||
matrix:
|
||||
- nodejs_version: "6"
|
||||
- nodejs_version: "7"
|
||||
- nodejs_version: "8"
|
||||
- nodejs_version: "9"
|
||||
- nodejs_version: "10"
|
||||
- nodejs_version: "12"
|
||||
- nodejs_version: "13"
|
||||
platform:
|
||||
- x86
|
||||
- x64
|
||||
install:
|
||||
- ps: Install-Product node $env:nodejs_version
|
||||
# https://github.com/appveyor/ci/issues/2921#issuecomment-501016533
|
||||
- ps: |
|
||||
try {
|
||||
Install-Product node $env:nodejs_version $env:platform
|
||||
} catch {
|
||||
echo "Unable to install node $env:nodejs_version, trying update..."
|
||||
Update-NodeJsInstallation (Get-NodeJsLatestBuild $env:nodejs_version) $env:platform
|
||||
}
|
||||
- md public
|
||||
- npm install --global --production windows-build-tools
|
||||
- npm install --global windows-build-tools
|
||||
- npm install
|
||||
build_script:
|
||||
- npm run build
|
||||
test_script:
|
||||
- npm run lint
|
||||
- npm test
|
||||
- npm run lint-js
|
||||
- npm run lint-css
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
machine:
|
||||
node:
|
||||
version: 6
|
||||
test:
|
||||
post:
|
||||
- npm run build
|
||||
@@ -1,48 +1,291 @@
|
||||
var webpack = require("webpack");
|
||||
var WebpackDevServer = require("webpack-dev-server");
|
||||
var webpackConfig = require("./webpack.production.config");
|
||||
var webpackConfig = require("./webpack.config");
|
||||
var testConfig = require("../test/config/specs");
|
||||
var artifacts = require("../test/artifacts");
|
||||
var isDocker = require("is-docker");
|
||||
|
||||
|
||||
var server;
|
||||
var SCREENSHOT_PATH = artifacts.pathSync("screenshots");
|
||||
|
||||
exports.config = {
|
||||
specs: [
|
||||
'./test/specs/**/*.js'
|
||||
],
|
||||
exclude: [
|
||||
],
|
||||
maxInstances: 10,
|
||||
capabilities: [{
|
||||
maxInstances: 5,
|
||||
browserName: 'firefox'
|
||||
}],
|
||||
sync: true,
|
||||
logLevel: 'verbose',
|
||||
coloredLogs: true,
|
||||
bail: 0,
|
||||
screenshotPath: './errorShots/',
|
||||
baseUrl: 'http://localhost',
|
||||
waitforTimeout: 10000,
|
||||
connectionRetryTimeout: 90000,
|
||||
connectionRetryCount: 3,
|
||||
services: ['phantomjs'],
|
||||
framework: 'mocha',
|
||||
reporters: ['spec'],
|
||||
phantomjsOpts: {
|
||||
webdriverLogfile: 'phantomjs.log'
|
||||
},
|
||||
mochaOpts: {
|
||||
ui: 'bdd',
|
||||
// Because we don't know how long the initial build will take...
|
||||
timeout: 2*60*1000
|
||||
},
|
||||
onPrepare: function (config, capabilities) {
|
||||
var compiler = webpack(webpackConfig);
|
||||
server = new WebpackDevServer(compiler, {});
|
||||
server.listen(testConfig.port);
|
||||
},
|
||||
onComplete: function(exitCode) {
|
||||
server.close();
|
||||
}
|
||||
//
|
||||
// ====================
|
||||
// Runner Configuration
|
||||
// ====================
|
||||
//
|
||||
// WebdriverIO allows it to run your tests in arbitrary locations (e.g. locally or
|
||||
// on a remote machine).
|
||||
runner: 'local',
|
||||
//
|
||||
// ==================
|
||||
// Specify Test Files
|
||||
// ==================
|
||||
// Define which test specs should run. The pattern is relative to the directory
|
||||
// from which `wdio` was called. Notice that, if you are calling `wdio` from an
|
||||
// NPM script (see https://docs.npmjs.com/cli/run-script) then the current working
|
||||
// directory is where your package.json resides, so `wdio` will be called from there.
|
||||
//
|
||||
specs: [
|
||||
'./test/functional/index.js'
|
||||
],
|
||||
// Patterns to exclude.
|
||||
exclude: [
|
||||
// 'path/to/excluded/files'
|
||||
],
|
||||
//
|
||||
// ============
|
||||
// Capabilities
|
||||
// ============
|
||||
// Define your capabilities here. WebdriverIO can run multiple capabilities at the same
|
||||
// time. Depending on the number of capabilities, WebdriverIO launches several test
|
||||
// sessions. Within your capabilities you can overwrite the spec and exclude options in
|
||||
// order to group specific specs to a specific capability.
|
||||
//
|
||||
// First, you can define how many instances should be started at the same time. Let's
|
||||
// say you have 3 different capabilities (Chrome, Firefox, and Safari) and you have
|
||||
// set maxInstances to 1; wdio will spawn 3 processes. Therefore, if you have 10 spec
|
||||
// files and you set maxInstances to 10, all spec files will get tested at the same time
|
||||
// and 30 processes will get spawned. The property handles how many capabilities
|
||||
// from the same test should run tests.
|
||||
//
|
||||
maxInstances: 10,
|
||||
//
|
||||
// If you have trouble getting all important capabilities together, check out the
|
||||
// Sauce Labs platform configurator - a great tool to configure your capabilities:
|
||||
// https://docs.saucelabs.com/reference/platforms-configurator
|
||||
//
|
||||
capabilities: [{
|
||||
// maxInstances can get overwritten per capability. So if you have an in-house Selenium
|
||||
// grid with only 5 firefox instances available you can make sure that not more than
|
||||
// 5 instances get started at a time.
|
||||
maxInstances: 5,
|
||||
//
|
||||
browserName: 'chrome',
|
||||
// If outputDir is provided WebdriverIO can capture driver session logs
|
||||
// it is possible to configure which logTypes to include/exclude.
|
||||
// excludeDriverLogs: ['*'], // pass '*' to exclude all driver session logs
|
||||
// excludeDriverLogs: ['bugreport', 'server'],
|
||||
}],
|
||||
//
|
||||
// ===================
|
||||
// Test Configurations
|
||||
// ===================
|
||||
// Define all options that are relevant for the WebdriverIO instance here
|
||||
//
|
||||
// Level of logging verbosity: trace | debug | info | warn | error | silent
|
||||
logLevel: 'info',
|
||||
//
|
||||
// Set specific log levels per logger
|
||||
// loggers:
|
||||
// - webdriver, webdriverio
|
||||
// - @wdio/applitools-service, @wdio/browserstack-service, @wdio/devtools-service, @wdio/sauce-service
|
||||
// - @wdio/mocha-framework, @wdio/jasmine-framework
|
||||
// - @wdio/local-runner, @wdio/lambda-runner
|
||||
// - @wdio/sumologic-reporter
|
||||
// - @wdio/cli, @wdio/config, @wdio/sync, @wdio/utils
|
||||
// Level of logging verbosity: trace | debug | info | warn | error | silent
|
||||
// logLevels: {
|
||||
// webdriver: 'debug',
|
||||
// '@wdio/applitools-service': 'info'
|
||||
// },
|
||||
//
|
||||
// If you only want to run your tests until a specific amount of tests have failed use
|
||||
// bail (default is 0 - don't bail, run all tests).
|
||||
bail: 0,
|
||||
//
|
||||
screenshotPath: SCREENSHOT_PATH,
|
||||
// Note: This is here because @orangemug currently runs Maputnik inside a docker container.
|
||||
hostname: process.env.DOCKER_HOST || "0.0.0.0",
|
||||
// Set a base URL in order to shorten url command calls. If your `url` parameter starts
|
||||
// with `/`, the base url gets prepended, not including the path portion of your baseUrl.
|
||||
// If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url
|
||||
// gets prepended directly.
|
||||
baseUrl: 'http://localhost',
|
||||
//
|
||||
// Default timeout for all waitFor* commands.
|
||||
waitforTimeout: 10000,
|
||||
//
|
||||
// Default timeout in milliseconds for request
|
||||
// if Selenium Grid doesn't send response
|
||||
connectionRetryTimeout: 90000,
|
||||
//
|
||||
// Default request retries count
|
||||
connectionRetryCount: 3,
|
||||
//
|
||||
// Test runner services
|
||||
// Services take over a specific job you don't want to take care of. They enhance
|
||||
// your test setup with almost no effort. Unlike plugins, they don't add new
|
||||
// commands. Instead, they hook themselves up into the test process.
|
||||
//
|
||||
// Framework you want to run your specs with.
|
||||
// The following are supported: Mocha, Jasmine, and Cucumber
|
||||
// see also: https://webdriver.io/docs/frameworks.html
|
||||
//
|
||||
// Make sure you have the wdio adapter package for the specific framework installed
|
||||
// before running any tests.
|
||||
framework: 'mocha',
|
||||
//
|
||||
// The number of times to retry the entire specfile when it fails as a whole
|
||||
// specFileRetries: 1,
|
||||
//
|
||||
// Test reporter for stdout.
|
||||
// The only one supported by default is 'dot'
|
||||
// see also: https://webdriver.io/docs/dot-reporter.html
|
||||
reporters: ['spec'],
|
||||
|
||||
//
|
||||
// Options to be passed to Mocha.
|
||||
// See the full list at http://mochajs.org/
|
||||
mochaOpts: {
|
||||
ui: 'bdd',
|
||||
// Because we don't know how long the initial build will take...
|
||||
timeout: 4*60*1000
|
||||
},
|
||||
onPrepare: function (config, capabilities) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var compiler = webpack(webpackConfig);
|
||||
const serverHost = isDocker() ? "0.0.0.0" : "localhost";
|
||||
|
||||
server = new WebpackDevServer(compiler, {
|
||||
host: serverHost,
|
||||
stats: {
|
||||
colors: true
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(testConfig.port, serverHost, function(err) {
|
||||
if(err) {
|
||||
reject(err);
|
||||
}
|
||||
else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
})
|
||||
},
|
||||
onComplete: function(exitCode) {
|
||||
server.close()
|
||||
}
|
||||
//
|
||||
// =====
|
||||
// Hooks
|
||||
// =====
|
||||
// WebdriverIO provides several hooks you can use to interfere with the test process in order to enhance
|
||||
// it and to build services around it. You can either apply a single function or an array of
|
||||
// methods to it. If one of them returns with a promise, WebdriverIO will wait until that promise got
|
||||
// resolved to continue.
|
||||
/**
|
||||
* Gets executed once before all workers get launched.
|
||||
* @param {Object} config wdio configuration object
|
||||
* @param {Array.<Object>} capabilities list of capabilities details
|
||||
*/
|
||||
// onPrepare: function (config, capabilities) {
|
||||
// },
|
||||
/**
|
||||
* Gets executed just before initialising the webdriver session and test framework. It allows you
|
||||
* to manipulate configurations depending on the capability or spec.
|
||||
* @param {Object} config wdio configuration object
|
||||
* @param {Array.<Object>} capabilities list of capabilities details
|
||||
* @param {Array.<String>} specs List of spec file paths that are to be run
|
||||
*/
|
||||
// beforeSession: function (config, capabilities, specs) {
|
||||
// },
|
||||
/**
|
||||
* Gets executed before test execution begins. At this point you can access to all global
|
||||
* variables like `browser`. It is the perfect place to define custom commands.
|
||||
* @param {Array.<Object>} capabilities list of capabilities details
|
||||
* @param {Array.<String>} specs List of spec file paths that are to be run
|
||||
*/
|
||||
// before: function (capabilities, specs) {
|
||||
// },
|
||||
/**
|
||||
* Runs before a WebdriverIO command gets executed.
|
||||
* @param {String} commandName hook command name
|
||||
* @param {Array} args arguments that command would receive
|
||||
*/
|
||||
// beforeCommand: function (commandName, args) {
|
||||
// },
|
||||
|
||||
/**
|
||||
* Hook that gets executed before the suite starts
|
||||
* @param {Object} suite suite details
|
||||
*/
|
||||
// beforeSuite: function (suite) {
|
||||
// },
|
||||
/**
|
||||
* Function to be executed before a test (in Mocha/Jasmine) or a step (in Cucumber) starts.
|
||||
* @param {Object} test test details
|
||||
*/
|
||||
// beforeTest: function (test) {
|
||||
// },
|
||||
/**
|
||||
* Hook that gets executed _before_ a hook within the suite starts (e.g. runs before calling
|
||||
* beforeEach in Mocha)
|
||||
*/
|
||||
// beforeHook: function () {
|
||||
// },
|
||||
/**
|
||||
* Hook that gets executed _after_ a hook within the suite starts (e.g. runs after calling
|
||||
* afterEach in Mocha)
|
||||
*/
|
||||
// afterHook: function () {
|
||||
// },
|
||||
/**
|
||||
* Function to be executed after a test (in Mocha/Jasmine) or a step (in Cucumber) starts.
|
||||
* @param {Object} test test details
|
||||
*/
|
||||
// afterTest: function (test) {
|
||||
// },
|
||||
/**
|
||||
* Hook that gets executed after the suite has ended
|
||||
* @param {Object} suite suite details
|
||||
*/
|
||||
// afterSuite: function (suite) {
|
||||
// },
|
||||
|
||||
/**
|
||||
* Runs after a WebdriverIO command gets executed
|
||||
* @param {String} commandName hook command name
|
||||
* @param {Array} args arguments that command would receive
|
||||
* @param {Number} result 0 - command success, 1 - command error
|
||||
* @param {Object} error error object if any
|
||||
*/
|
||||
// afterCommand: function (commandName, args, result, error) {
|
||||
// },
|
||||
/**
|
||||
* Gets executed after all tests are done. You still have access to all global variables from
|
||||
* the test.
|
||||
* @param {Number} result 0 - test pass, 1 - test fail
|
||||
* @param {Array.<Object>} capabilities list of capabilities details
|
||||
* @param {Array.<String>} specs List of spec file paths that ran
|
||||
*/
|
||||
// after: function (result, capabilities, specs) {
|
||||
// },
|
||||
/**
|
||||
* Gets executed right after terminating the webdriver session.
|
||||
* @param {Object} config wdio configuration object
|
||||
* @param {Array.<Object>} capabilities list of capabilities details
|
||||
* @param {Array.<String>} specs List of spec file paths that ran
|
||||
*/
|
||||
// afterSession: function (config, capabilities, specs) {
|
||||
// },
|
||||
/**
|
||||
* Gets executed after all workers got shut down and the process is about to exit. An error
|
||||
* thrown in the onComplete hook will result in the test run failing.
|
||||
* @param {Object} exitCode 0 - success, 1 - fail
|
||||
* @param {Object} config wdio configuration object
|
||||
* @param {Array.<Object>} capabilities list of capabilities details
|
||||
* @param {<Object>} results object containing test results
|
||||
*/
|
||||
// onComplete: function(exitCode, config, capabilities, results) {
|
||||
// },
|
||||
/**
|
||||
* Gets executed when a refresh happens.
|
||||
* @param {String} oldSessionId session ID of the old session
|
||||
* @param {String} newSessionId session ID of the new session
|
||||
*/
|
||||
//onReload: function(oldSessionId, newSessionId) {
|
||||
//}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use strict";
|
||||
var webpack = require('webpack');
|
||||
var path = require('path');
|
||||
var loaders = require('./webpack.loaders');
|
||||
var rules = require('./webpack.rules');
|
||||
var HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
var HtmlWebpackInlineSVGPlugin = require('html-webpack-inline-svg-plugin');
|
||||
var CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
|
||||
const HOST = process.env.HOST || "127.0.0.1";
|
||||
@@ -10,6 +11,7 @@ const PORT = process.env.PORT || "8888";
|
||||
|
||||
module.exports = {
|
||||
target: 'web',
|
||||
mode: 'development',
|
||||
entry: [
|
||||
`webpack-dev-server/client?http://${HOST}:${PORT}`,
|
||||
`webpack/hot/only-dev-server`,
|
||||
@@ -25,10 +27,9 @@ module.exports = {
|
||||
},
|
||||
module: {
|
||||
noParse: [
|
||||
/mapbox-gl\/dist\/mapbox-gl.js/,
|
||||
/openlayers\/dist\/ol.js/
|
||||
/mapbox-gl\/dist\/mapbox-gl.js/
|
||||
],
|
||||
loaders: loaders
|
||||
rules: rules
|
||||
},
|
||||
node: {
|
||||
fs: "empty",
|
||||
@@ -61,6 +62,9 @@ module.exports = {
|
||||
title: 'Maputnik',
|
||||
template: './src/template.html'
|
||||
}),
|
||||
new HtmlWebpackInlineSVGPlugin({
|
||||
runPreEmit: true,
|
||||
}),
|
||||
new CopyWebpackPlugin([
|
||||
{
|
||||
from: './src/manifest.json',
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
module.exports = [
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
exclude: /(node_modules|bower_components|public)/,
|
||||
loaders: ['react-hot-loader/webpack']
|
||||
},
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
exclude: /(.*node_modules(?![\/\\]@mapbox[\/\\]mapbox-gl-style-spec)|bower_components|public)/,
|
||||
loader: 'babel-loader',
|
||||
query: {
|
||||
presets: ['env', 'react'],
|
||||
plugins: ['transform-runtime', 'transform-decorators-legacy', 'transform-class-properties'],
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.(eot|ttf|woff|woff2)$/,
|
||||
loader: 'file-loader?name=fonts/[name].[ext]'
|
||||
},
|
||||
{
|
||||
test: /\.ico$/,
|
||||
loader: 'file-loader?name=[name].[ext]'
|
||||
},
|
||||
{
|
||||
test: /\.(svg|gif|jpg|png)$/,
|
||||
loader: 'file-loader?name=img/[name].[ext]'
|
||||
},
|
||||
{
|
||||
test: /\.json$/,
|
||||
loader: 'json-loader'
|
||||
},
|
||||
{
|
||||
test: /[\/\\](node_modules|global|src)[\/\\].*\.scss$/,
|
||||
loaders: ["style-loader", "css-loader", "sass-loader"]
|
||||
},
|
||||
{
|
||||
test: /[\/\\](node_modules|global|src)[\/\\].*\.css$/,
|
||||
loaders: [
|
||||
'style-loader?sourceMap',
|
||||
'css-loader'
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -1,58 +1,32 @@
|
||||
|
||||
var webpack = require('webpack');
|
||||
var path = require('path');
|
||||
var loaders = require('./webpack.loaders');
|
||||
var ExtractTextPlugin = require('extract-text-webpack-plugin');
|
||||
var rules = require('./webpack.rules');
|
||||
var HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
var HtmlWebpackInlineSVGPlugin = require('html-webpack-inline-svg-plugin');
|
||||
var WebpackCleanupPlugin = require('webpack-cleanup-plugin');
|
||||
var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
||||
var CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
var artifacts = require("../test/artifacts");
|
||||
|
||||
var OUTPATH;
|
||||
if(process.env.CIRCLE_ARTIFACTS) {
|
||||
OUTPATH = path.join(process.env.CIRCLE_ARTIFACTS, "build");
|
||||
}
|
||||
else {
|
||||
OUTPATH = path.join(__dirname, '..', 'public');
|
||||
}
|
||||
var OUTPATH = artifacts.pathSync("/build");
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
app: './src/index.jsx',
|
||||
vendor: [
|
||||
'file-saver',
|
||||
'mapbox-gl/dist/mapbox-gl.js',
|
||||
"lodash.clonedeep",
|
||||
"lodash.throttle",
|
||||
'color',
|
||||
'react',
|
||||
"react-dom",
|
||||
"react-color",
|
||||
"react-file-reader-input",
|
||||
"react-collapse",
|
||||
"react-height",
|
||||
"react-icon-base",
|
||||
"react-motion",
|
||||
"react-sortable-hoc",
|
||||
"request",
|
||||
//TODO: Icons raise multi vendor errors?
|
||||
//"react-icons",
|
||||
]
|
||||
},
|
||||
output: {
|
||||
path: OUTPATH,
|
||||
filename: '[name].[chunkhash].js',
|
||||
chunkFilename: '[chunkhash].js'
|
||||
filename: '[name].[contenthash].js',
|
||||
chunkFilename: '[contenthash].js'
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx']
|
||||
},
|
||||
module: {
|
||||
noParse: [
|
||||
/mapbox-gl\/dist\/mapbox-gl.js/,
|
||||
/openlayers\/dist\/ol.js/
|
||||
/mapbox-gl\/dist\/mapbox-gl.js/
|
||||
],
|
||||
loaders
|
||||
rules: rules
|
||||
},
|
||||
node: {
|
||||
fs: "empty",
|
||||
@@ -61,32 +35,25 @@ module.exports = {
|
||||
},
|
||||
plugins: [
|
||||
new webpack.NoEmitOnErrorsPlugin(),
|
||||
new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', filename: '[chunkhash].vendor.js' }),
|
||||
new WebpackCleanupPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env': {
|
||||
NODE_ENV: '"production"'
|
||||
}
|
||||
}),
|
||||
new webpack.optimize.UglifyJsPlugin({
|
||||
compress: {
|
||||
warnings: false,
|
||||
screw_ie8: true,
|
||||
}
|
||||
}),
|
||||
new ExtractTextPlugin('[contenthash].css', {
|
||||
allChunks: true
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: './src/template.html',
|
||||
title: 'Maputnik'
|
||||
}),
|
||||
new HtmlWebpackInlineSVGPlugin({
|
||||
runPreEmit: true,
|
||||
}),
|
||||
new CopyWebpackPlugin([
|
||||
{
|
||||
from: './src/manifest.json',
|
||||
to: 'manifest.json'
|
||||
}
|
||||
]),
|
||||
}
|
||||
]),
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: 'static',
|
||||
defaultSizes: 'gzip',
|
||||
|
||||
20
config/webpack.profiling.config.js
Normal file
20
config/webpack.profiling.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const webpackProdConfig = require('./webpack.production.config');
|
||||
const artifacts = require("../test/artifacts");
|
||||
|
||||
const OUTPATH = artifacts.pathSync("/profiling");
|
||||
|
||||
module.exports = {
|
||||
...webpackProdConfig,
|
||||
output: {
|
||||
...webpackProdConfig.output,
|
||||
path: OUTPATH,
|
||||
},
|
||||
resolve: {
|
||||
...webpackProdConfig.resolve,
|
||||
alias: {
|
||||
...webpackProdConfig.resolve.alias,
|
||||
'react-dom$': 'react-dom/profiling',
|
||||
'scheduler/tracing': 'scheduler/tracing-profiling',
|
||||
}
|
||||
}
|
||||
};
|
||||
44
config/webpack.rules.js
Normal file
44
config/webpack.rules.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const path = require("path");
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
exclude: [
|
||||
path.resolve(__dirname, '../node_modules')
|
||||
],
|
||||
use: 'babel-loader'
|
||||
},
|
||||
{
|
||||
test: /\.(eot|ttf|woff|woff2)$/,
|
||||
use: 'file-loader?name=fonts/[name].[ext]'
|
||||
},
|
||||
{
|
||||
test: /\.ico$/,
|
||||
use: 'file-loader?name=[name].[ext]'
|
||||
},
|
||||
{
|
||||
test: /\.(gif|jpg|png)$/,
|
||||
use: 'file-loader?name=img/[name].[ext]'
|
||||
},
|
||||
{
|
||||
test: /\.svg$/,
|
||||
use: [
|
||||
'svg-inline-loader'
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /[\/\\](node_modules|global|src)[\/\\].*\.scss$/,
|
||||
use: [
|
||||
'style-loader',
|
||||
"css-loader",
|
||||
"sass-loader"
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /[\/\\](node_modules|global|src)[\/\\].*\.css$/,
|
||||
use: [
|
||||
'style-loader',
|
||||
'css-loader'
|
||||
]
|
||||
}
|
||||
];
|
||||
22944
package-lock.json
generated
22944
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
181
package.json
181
package.json
@@ -1,16 +1,18 @@
|
||||
{
|
||||
"name": "maputnik",
|
||||
"version": "1.1.0-beta2",
|
||||
"version": "1.7.0-beta2",
|
||||
"description": "A MapboxGL visual style editor",
|
||||
"main": "''",
|
||||
"scripts": {
|
||||
"stats": "webpack --config config/webpack.production.config.js --profile --json > stats.json",
|
||||
"build": "webpack --config config/webpack.production.config.js --progress --profile --colors",
|
||||
"test": "wdio config/wdio.conf.js",
|
||||
"test-watch": "wdio config/wdio.conf.js --watch",
|
||||
"profiling-build": "webpack --config config/webpack.profiling.config.js --progress --profile --colors",
|
||||
"test": "cross-env NODE_ENV=test wdio config/wdio.conf.js",
|
||||
"test-watch": "cross-env NODE_ENV=test wdio config/wdio.conf.js --watch",
|
||||
"start": "webpack-dev-server --progress --profile --colors --config config/webpack.config.js",
|
||||
"lint": "eslint --ext js --ext jsx {src,test}",
|
||||
"lint-styles": "stylelint 'src/styles/*.scss'"
|
||||
"lint-js": "eslint --ext js --ext jsx src test",
|
||||
"lint-css": "stylelint \"src/styles/*.scss\"",
|
||||
"lint": "npm run lint-js && npm run lint-css"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -20,48 +22,65 @@
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/maputnik/editor#readme",
|
||||
"dependencies": {
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.1.1",
|
||||
"@mapbox/mapbox-gl-style-spec": "^10.0.1",
|
||||
"classnames": "^2.2.5",
|
||||
"codemirror": "^5.32.0",
|
||||
"color": "^2.0.0",
|
||||
"file-saver": "^1.3.3",
|
||||
"github-api": "^3.0.0",
|
||||
"@babel/runtime": "^7.8.4",
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.2.3",
|
||||
"@mapbox/mapbox-gl-style-spec": "^13.12.0",
|
||||
"@mdi/react": "^1.4.0",
|
||||
"array-move": "^2.2.1",
|
||||
"classnames": "^2.2.6",
|
||||
"codemirror": "^5.52.0",
|
||||
"color": "^3.1.2",
|
||||
"detect-browser": "^5.0.0",
|
||||
"file-saver": "^2.0.2",
|
||||
"json-to-ast": "^2.1.0",
|
||||
"jsonlint": "github:josdejong/jsonlint#85a19d7",
|
||||
"lodash": "^4.17.15",
|
||||
"lodash.capitalize": "^4.2.1",
|
||||
"lodash.clamp": "^4.0.3",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mapbox-gl": "^0.43.0",
|
||||
"mapbox-gl-inspect": "^1.2.5",
|
||||
"maputnik-design": "github:maputnik/design",
|
||||
"mousetrap": "^1.6.1",
|
||||
"ol-mapbox-style": "^1.0.1",
|
||||
"openlayers": "^4.4.2",
|
||||
"prop-types": "^15.6.0",
|
||||
"react": "16.0.0",
|
||||
"react-addons-pure-render-mixin": "^15.6.2",
|
||||
"react-autocomplete": "^1.7.2",
|
||||
"react-codemirror2": "^3.0.7",
|
||||
"react-collapse": "^4.0.3",
|
||||
"react-color": "^2.13.8",
|
||||
"react-copy-to-clipboard": "^5.0.1",
|
||||
"react-dom": "16.0.0",
|
||||
"react-file-reader-input": "^1.1.4",
|
||||
"react-height": "^3.0.0",
|
||||
"react-icon-base": "^2.1.1",
|
||||
"react-icons": "^2.2.7",
|
||||
"mapbox-gl": "^1.9.1",
|
||||
"mapbox-gl-inspect": "^1.3.1",
|
||||
"maputnik-design": "github:maputnik/design#f7a2b4d",
|
||||
"ol": "^6.3.1",
|
||||
"ol-mapbox-style": "^6.0.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.12.0",
|
||||
"react-accessible-accordion": "^3.0.1",
|
||||
"react-aria-menubutton": "^6.3.0",
|
||||
"react-aria-modal": "^4.0.0",
|
||||
"react-autobind": "^1.0.6",
|
||||
"react-autocomplete": "^1.8.1",
|
||||
"react-collapse": "^5.0.1",
|
||||
"react-color": "^2.18.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-file-reader-input": "^2.0.0",
|
||||
"react-icon-base": "^2.1.2",
|
||||
"react-icons": "^3.9.0",
|
||||
"react-motion": "^0.5.2",
|
||||
"react-sortable-hoc": "^0.6.8",
|
||||
"reconnecting-websocket": "^3.2.2",
|
||||
"request": "^2.83.0",
|
||||
"react-sortable-hoc": "^1.11.0",
|
||||
"reconnecting-websocket": "^4.4.0",
|
||||
"slugify": "^1.3.6",
|
||||
"url": "^0.11.0"
|
||||
},
|
||||
"jshintConfig": {
|
||||
"esversion": 6
|
||||
},
|
||||
"stylelint": {
|
||||
"extends": "stylelint-config-standard"
|
||||
"extends": "stylelint-config-recommended-scss",
|
||||
"rules": {
|
||||
"no-descending-specificity": null,
|
||||
"media-feature-name-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreMediaFeatureNames": [
|
||||
"prefers-reduced-motion"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"eslintConfig": {
|
||||
"plugins": [
|
||||
@@ -84,50 +103,62 @@
|
||||
"experimentalObjectRestSpread": true,
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-core": "^6.26.0",
|
||||
"babel-eslint": "^8.0.2",
|
||||
"babel-loader": "7.1.1",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"babel-plugin-transform-decorators-legacy": "^1.3.4",
|
||||
"babel-plugin-transform-flow-strip-types": "^6.22.0",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||
"babel-plugin-transform-runtime": "^6.23.0",
|
||||
"babel-preset-env": "^1.6.1",
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"babel-runtime": "^6.26.0",
|
||||
"base64-loader": "^1.0.0",
|
||||
"copy-webpack-plugin": "^4.2.0",
|
||||
"css-loader": "^0.28.7",
|
||||
"eslint": "^4.10.0",
|
||||
"eslint-plugin-react": "^7.4.0",
|
||||
"extract-text-webpack-plugin": "^3.0.2",
|
||||
"file-loader": "^1.1.5",
|
||||
"html-webpack-plugin": "^2.30.1",
|
||||
"json-loader": "^0.5.7",
|
||||
"karma": "^1.7.1",
|
||||
"karma-chrome-launcher": "^2.2.0",
|
||||
"karma-firefox-launcher": "^1.0.1",
|
||||
"karma-mocha": "^1.3.0",
|
||||
"karma-webpack": "^2.0.5",
|
||||
"mocha": "^4.0.1",
|
||||
"mocha-loader": "^1.1.1",
|
||||
"node-sass": "^4.6.0",
|
||||
"react-hot-loader": "^3.1.1",
|
||||
"sass-loader": "^6.0.6",
|
||||
"style-loader": "^0.19.0",
|
||||
"stylelint": "^7.13.0",
|
||||
"stylelint-config-standard": "^15.0.1",
|
||||
"@babel/core": "^7.8.4",
|
||||
"@babel/plugin-proposal-class-properties": "^7.5.5",
|
||||
"@babel/plugin-transform-runtime": "^7.6.2",
|
||||
"@babel/preset-env": "^7.6.3",
|
||||
"@babel/preset-flow": "^7.0.0",
|
||||
"@babel/preset-react": "^7.6.3",
|
||||
"@mdi/js": "^5.0.45",
|
||||
"@wdio/cli": "^6.0.5",
|
||||
"@wdio/local-runner": "^6.0.5",
|
||||
"@wdio/mocha-framework": "^6.0.4",
|
||||
"@wdio/selenium-standalone-service": "^6.0.4",
|
||||
"@wdio/spec-reporter": "^6.0.4",
|
||||
"@wdio/sync": "^6.0.1",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"babel-loader": "^8.1.0",
|
||||
"babel-plugin-istanbul": "^6.0.0",
|
||||
"babel-plugin-static-fs": "^3.0.0",
|
||||
"copy-webpack-plugin": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"cross-env": "^7.0.0",
|
||||
"css-loader": "^3.4.2",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-plugin-react": "^7.18.3",
|
||||
"express": "^4.17.1",
|
||||
"file-loader": "^6.0.0",
|
||||
"html-webpack-inline-svg-plugin": "^1.3.0",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"is-docker": "^2.0.0",
|
||||
"istanbul": "^0.4.5",
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"mkdirp": "^1.0.4",
|
||||
"mocha": "^7.0.1",
|
||||
"node-sass": "^4.13.1",
|
||||
"react-hot-loader": "^4.12.19",
|
||||
"sass-loader": "^8.0.2",
|
||||
"selenium-standalone": "^6.17.0",
|
||||
"style-loader": "^1.1.3",
|
||||
"stylelint": "^13.3.0",
|
||||
"stylelint-config-recommended-scss": "^4.2.0",
|
||||
"stylelint-scss": "^3.14.2",
|
||||
"svg-inline-loader": "^0.8.2",
|
||||
"transform-loader": "^0.2.4",
|
||||
"wdio-mocha-framework": "^0.5.11",
|
||||
"wdio-phantomjs-service": "^0.2.2",
|
||||
"wdio-spec-reporter": "^0.1.2",
|
||||
"webdriverio": "^4.8.0",
|
||||
"webpack": "^3.8.1",
|
||||
"webpack-bundle-analyzer": "^2.9.0",
|
||||
"uuid": "^7.0.3",
|
||||
"webdriverio": "^6.0.5",
|
||||
"webpack": "^4.41.6",
|
||||
"webpack-bundle-analyzer": "^3.6.0",
|
||||
"webpack-cleanup-plugin": "^0.5.1",
|
||||
"webpack-dev-server": "^2.9.4"
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack-dev-server": "^3.10.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,78 @@
|
||||
import autoBind from 'react-autobind';
|
||||
import React from 'react'
|
||||
import Mousetrap from 'mousetrap'
|
||||
import cloneDeep from 'lodash.clonedeep'
|
||||
import clamp from 'lodash.clamp'
|
||||
import get from 'lodash.get'
|
||||
import {unset} from 'lodash'
|
||||
import arrayMove from 'array-move'
|
||||
import url from 'url'
|
||||
|
||||
import MapboxGlMap from './map/MapboxGlMap'
|
||||
import OpenLayers3Map from './map/OpenLayers3Map'
|
||||
import OpenLayersMap from './map/OpenLayersMap'
|
||||
import LayerList from './layers/LayerList'
|
||||
import LayerEditor from './layers/LayerEditor'
|
||||
import Toolbar from './Toolbar'
|
||||
import AppLayout from './AppLayout'
|
||||
import MessagePanel from './MessagePanel'
|
||||
|
||||
import SettingsModal from './modals/SettingsModal'
|
||||
import ExportModal from './modals/ExportModal'
|
||||
import SourcesModal from './modals/SourcesModal'
|
||||
import OpenModal from './modals/OpenModal'
|
||||
import ShortcutsModal from './modals/ShortcutsModal'
|
||||
import SurveyModal from './modals/SurveyModal'
|
||||
import DebugModal from './modals/DebugModal'
|
||||
|
||||
import { downloadGlyphsMetadata, downloadSpriteMetadata } from '../libs/metadata'
|
||||
import styleSpec from '@mapbox/mapbox-gl-style-spec/style-spec'
|
||||
import style from '../libs/style.js'
|
||||
import { initialStyleUrl, loadStyleUrl } from '../libs/urlopen'
|
||||
import {latest, validate} from '@mapbox/mapbox-gl-style-spec'
|
||||
import style from '../libs/style'
|
||||
import { initialStyleUrl, loadStyleUrl, removeStyleQuerystring } from '../libs/urlopen'
|
||||
import { undoMessages, redoMessages } from '../libs/diffmessage'
|
||||
import { loadDefaultStyle, StyleStore } from '../libs/stylestore'
|
||||
import { StyleStore } from '../libs/stylestore'
|
||||
import { ApiStyleStore } from '../libs/apistore'
|
||||
import { RevisionStore } from '../libs/revisions'
|
||||
import LayerWatcher from '../libs/layerwatcher'
|
||||
import tokens from '../config/tokens.json'
|
||||
import isEqual from 'lodash.isequal'
|
||||
import Debug from '../libs/debug'
|
||||
import queryUtil from '../libs/query-util'
|
||||
import {formatLayerId} from './util/format';
|
||||
|
||||
import MapboxGl from 'mapbox-gl'
|
||||
|
||||
|
||||
// Similar functionality as <https://github.com/mapbox/mapbox-gl-js/blob/7e30aadf5177486c2cfa14fe1790c60e217b5e56/src/util/mapbox.js>
|
||||
function normalizeSourceURL (url, apiToken="") {
|
||||
const matches = url.match(/^mapbox:\/\/(.*)/);
|
||||
if (matches) {
|
||||
// mapbox://mapbox.mapbox-streets-v7
|
||||
return `https://api.mapbox.com/v4/${matches[1]}.json?secure&access_token=${apiToken}`
|
||||
}
|
||||
else {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function setFetchAccessToken(url, mapStyle) {
|
||||
const matchesTilehosting = url.match(/\.tilehosting\.com/);
|
||||
const matchesMaptiler = url.match(/\.maptiler\.com/);
|
||||
const matchesThunderforest = url.match(/\.thunderforest\.com/);
|
||||
if (matchesTilehosting || matchesMaptiler) {
|
||||
const accessToken = style.getAccessToken("openmaptiles", mapStyle, {allowFallback: true})
|
||||
if (accessToken) {
|
||||
return url.replace('{key}', accessToken)
|
||||
}
|
||||
}
|
||||
else if (matchesThunderforest) {
|
||||
const accessToken = style.getAccessToken("thunderforest", mapStyle, {allowFallback: true})
|
||||
if (accessToken) {
|
||||
return url.replace('{key}', accessToken)
|
||||
}
|
||||
}
|
||||
else {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function updateRootSpec(spec, fieldName, newValues) {
|
||||
return {
|
||||
@@ -37,25 +90,121 @@ function updateRootSpec(spec, fieldName, newValues) {
|
||||
export default class App extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
autoBind(this);
|
||||
|
||||
this.revisionStore = new RevisionStore()
|
||||
const params = new URLSearchParams(window.location.search.substring(1))
|
||||
let port = params.get("localport")
|
||||
if (port == null && (window.location.port != 80 && window.location.port != 443)) {
|
||||
port = window.location.port
|
||||
}
|
||||
this.styleStore = new ApiStyleStore({
|
||||
onLocalStyleChange: mapStyle => this.onStyleChanged(mapStyle, false)
|
||||
onLocalStyleChange: mapStyle => this.onStyleChanged(mapStyle, {save: false}),
|
||||
port: port,
|
||||
host: params.get("localhost")
|
||||
})
|
||||
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
key: "?",
|
||||
handler: () => {
|
||||
this.toggleModal("shortcuts");
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "o",
|
||||
handler: () => {
|
||||
this.toggleModal("open");
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "e",
|
||||
handler: () => {
|
||||
this.toggleModal("export");
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "d",
|
||||
handler: () => {
|
||||
this.toggleModal("sources");
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "s",
|
||||
handler: () => {
|
||||
this.toggleModal("settings");
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "i",
|
||||
handler: () => {
|
||||
this.setMapState(
|
||||
this.state.mapState === "map" ? "inspect" : "map"
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "m",
|
||||
handler: () => {
|
||||
document.querySelector(".mapboxgl-canvas").focus();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "!",
|
||||
handler: () => {
|
||||
this.toggleModal("debug");
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
document.body.addEventListener("keyup", (e) => {
|
||||
if(e.key === "Escape") {
|
||||
e.target.blur();
|
||||
document.body.focus();
|
||||
}
|
||||
else if(this.state.isOpen.shortcuts || document.activeElement === document.body) {
|
||||
const shortcut = shortcuts.find((shortcut) => {
|
||||
return (shortcut.key === e.key)
|
||||
})
|
||||
|
||||
if(shortcut) {
|
||||
this.setModal("shortcuts", false);
|
||||
shortcut.handler(e);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const styleUrl = initialStyleUrl()
|
||||
if(styleUrl) {
|
||||
if(styleUrl && window.confirm("Load style from URL: " + styleUrl + " and discard current changes?")) {
|
||||
this.styleStore = new StyleStore()
|
||||
loadStyleUrl(styleUrl, mapStyle => this.onStyleChanged(mapStyle))
|
||||
removeStyleQuerystring()
|
||||
} else {
|
||||
if(styleUrl) {
|
||||
removeStyleQuerystring()
|
||||
}
|
||||
this.styleStore.init(err => {
|
||||
if(err) {
|
||||
console.log('Falling back to local storage for storing styles')
|
||||
this.styleStore = new StyleStore()
|
||||
}
|
||||
this.styleStore.latestStyle(mapStyle => this.onStyleChanged(mapStyle))
|
||||
|
||||
if(Debug.enabled()) {
|
||||
Debug.set("maputnik", "styleStore", this.styleStore);
|
||||
Debug.set("maputnik", "revisionStore", this.revisionStore);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if(Debug.enabled()) {
|
||||
Debug.set("maputnik", "revisionStore", this.revisionStore);
|
||||
Debug.set("maputnik", "styleStore", this.styleStore);
|
||||
}
|
||||
|
||||
const queryObj = url.parse(window.location.href, true).query;
|
||||
|
||||
this.state = {
|
||||
errors: [],
|
||||
infos: [],
|
||||
@@ -63,8 +212,33 @@ export default class App extends React.Component {
|
||||
selectedLayerIndex: 0,
|
||||
sources: {},
|
||||
vectorLayers: {},
|
||||
inspectModeEnabled: false,
|
||||
spec: styleSpec.latest,
|
||||
mapState: "map",
|
||||
spec: latest,
|
||||
mapView: {
|
||||
zoom: 0,
|
||||
center: {
|
||||
lng: 0,
|
||||
lat: 0,
|
||||
},
|
||||
},
|
||||
isOpen: {
|
||||
settings: false,
|
||||
sources: false,
|
||||
open: false,
|
||||
shortcuts: false,
|
||||
export: false,
|
||||
// TODO: Disabled for now, this should be opened on the Nth visit to the editor
|
||||
survey: false,
|
||||
debug: false,
|
||||
},
|
||||
mapboxGlDebugOptions: {
|
||||
showTileBoundaries: false,
|
||||
showCollisionBoxes: false,
|
||||
showOverdrawInspector: false,
|
||||
},
|
||||
openlayersDebugOptions: {
|
||||
debugToolbox: false,
|
||||
},
|
||||
}
|
||||
|
||||
this.layerWatcher = new LayerWatcher({
|
||||
@@ -72,20 +246,35 @@ export default class App extends React.Component {
|
||||
})
|
||||
}
|
||||
|
||||
handleKeyPress = (e) => {
|
||||
if(navigator.platform.toUpperCase().indexOf('MAC') >= 0) {
|
||||
if(e.metaKey && e.shiftKey && e.keyCode === 90) {
|
||||
e.preventDefault();
|
||||
this.onRedo(e);
|
||||
}
|
||||
else if(e.metaKey && e.keyCode === 90) {
|
||||
e.preventDefault();
|
||||
this.onUndo(e);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if(e.ctrlKey && e.keyCode === 90) {
|
||||
e.preventDefault();
|
||||
this.onUndo(e);
|
||||
}
|
||||
else if(e.ctrlKey && e.keyCode === 89) {
|
||||
e.preventDefault();
|
||||
this.onRedo(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchSources();
|
||||
Mousetrap.bind(['ctrl+z'], this.onUndo.bind(this));
|
||||
Mousetrap.bind(['ctrl+y'], this.onRedo.bind(this));
|
||||
window.addEventListener("keydown", this.handleKeyPress);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
Mousetrap.unbind(['ctrl+z'], this.onUndo.bind(this));
|
||||
Mousetrap.unbind(['ctrl+y'], this.onRedo.bind(this));
|
||||
}
|
||||
|
||||
onReset() {
|
||||
this.styleStore.purge()
|
||||
loadDefaultStyle(mapStyle => this.onStyleOpen(mapStyle))
|
||||
window.removeEventListener("keydown", this.handleKeyPress);
|
||||
}
|
||||
|
||||
saveStyle(snapshotStyle) {
|
||||
@@ -95,7 +284,9 @@ export default class App extends React.Component {
|
||||
updateFonts(urlTemplate) {
|
||||
const metadata = this.state.mapStyle.metadata || {}
|
||||
const accessToken = metadata['maputnik:openmaptiles_access_token'] || tokens.openmaptiles
|
||||
downloadGlyphsMetadata(urlTemplate.replace('{key}', accessToken), fonts => {
|
||||
|
||||
let glyphUrl = (typeof urlTemplate === 'string')? urlTemplate.replace('{key}', accessToken): urlTemplate;
|
||||
downloadGlyphsMetadata(glyphUrl, fonts => {
|
||||
this.setState({ spec: updateRootSpec(this.state.spec, 'glyphs', fonts)})
|
||||
})
|
||||
}
|
||||
@@ -106,7 +297,114 @@ export default class App extends React.Component {
|
||||
})
|
||||
}
|
||||
|
||||
onStyleChanged(newStyle, save=true) {
|
||||
onChangeMetadataProperty = (property, value) => {
|
||||
// If we're changing renderer reset the map state.
|
||||
if (
|
||||
property === 'maputnik:renderer' &&
|
||||
value !== get(this.state.mapStyle, ['metadata', 'maputnik:renderer'], 'mbgljs')
|
||||
) {
|
||||
this.setState({
|
||||
mapState: 'map'
|
||||
});
|
||||
}
|
||||
|
||||
const changedStyle = {
|
||||
...this.state.mapStyle,
|
||||
metadata: {
|
||||
...this.state.mapStyle.metadata,
|
||||
[property]: value
|
||||
}
|
||||
}
|
||||
this.onStyleChanged(changedStyle)
|
||||
}
|
||||
|
||||
onStyleChanged = (newStyle, opts={}) => {
|
||||
opts = {
|
||||
save: true,
|
||||
addRevision: true,
|
||||
...opts,
|
||||
};
|
||||
|
||||
const errors = validate(newStyle, latest) || [];
|
||||
|
||||
// The validate function doesn't give us errors for duplicate error with
|
||||
// empty string for layer.id, manually deal with that here.
|
||||
const layerErrors = [];
|
||||
if (newStyle && newStyle.layers) {
|
||||
const foundLayers = new Map();
|
||||
newStyle.layers.forEach((layer, index) => {
|
||||
if (layer.id === "" && foundLayers.has(layer.id)) {
|
||||
const message = `Duplicate layer: ${formatLayerId(layer.id)}`;
|
||||
const error = new Error(
|
||||
`layers[${index}]: duplicate layer id [empty_string], previously used`
|
||||
);
|
||||
layerErrors.push(error);
|
||||
}
|
||||
foundLayers.set(layer.id, true);
|
||||
});
|
||||
}
|
||||
|
||||
const mappedErrors = layerErrors.concat(errors).map(error => {
|
||||
const dupMatch = error.message.match(/layers\[(\d+)\]: (duplicate layer id "?(.*)"?, previously used)/);
|
||||
if (dupMatch) {
|
||||
const [matchStr, index, message] = dupMatch;
|
||||
return {
|
||||
message: error.message,
|
||||
parsed: {
|
||||
type: "layer",
|
||||
data: {
|
||||
index: parseInt(index, 10),
|
||||
key: "id",
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// duplicate layer id
|
||||
const layerMatch = error.message.match(/layers\[(\d+)\]\.(?:(\S+)\.)?(\S+): (.*)/);
|
||||
if (layerMatch) {
|
||||
const [matchStr, index, group, property, message] = layerMatch;
|
||||
const key = (group && property) ? [group, property].join(".") : property;
|
||||
return {
|
||||
message: error.message,
|
||||
parsed: {
|
||||
type: "layer",
|
||||
data: {
|
||||
index: parseInt(index, 10),
|
||||
key,
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
return {
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
let dirtyMapStyle = undefined;
|
||||
if (errors.length > 0) {
|
||||
dirtyMapStyle = cloneDeep(newStyle);
|
||||
|
||||
errors.forEach(error => {
|
||||
const {message} = error;
|
||||
if (message) {
|
||||
try {
|
||||
const objPath = message.split(":")[0];
|
||||
// Errors can be deply nested for example 'layers[0].filter[1][1][0]' we only care upto the property 'layers[0].filter'
|
||||
const unsetPath = objPath.match(/^\S+?\[\d+\]\.[^\[]+/)[0];
|
||||
unset(dirtyMapStyle, unsetPath);
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if(newStyle.glyphs !== this.state.mapStyle.glyphs) {
|
||||
this.updateFonts(newStyle.glyphs)
|
||||
}
|
||||
@@ -114,44 +412,60 @@ export default class App extends React.Component {
|
||||
this.updateIcons(newStyle.sprite)
|
||||
}
|
||||
|
||||
const errors = styleSpec.validate(newStyle, styleSpec.latest)
|
||||
if(errors.length === 0) {
|
||||
this.revisionStore.addRevision(newStyle)
|
||||
if(save) this.saveStyle(newStyle)
|
||||
this.setState({
|
||||
mapStyle: newStyle,
|
||||
errors: [],
|
||||
})
|
||||
} else {
|
||||
this.setState({
|
||||
errors: errors.map(err => err.message)
|
||||
})
|
||||
if (opts.addRevision) {
|
||||
this.revisionStore.addRevision(newStyle);
|
||||
}
|
||||
if (opts.save) {
|
||||
this.saveStyle(newStyle);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
mapStyle: newStyle,
|
||||
dirtyMapStyle: dirtyMapStyle,
|
||||
errors: mappedErrors,
|
||||
})
|
||||
|
||||
this.fetchSources();
|
||||
}
|
||||
|
||||
onUndo() {
|
||||
onUndo = () => {
|
||||
const activeStyle = this.revisionStore.undo()
|
||||
|
||||
const messages = undoMessages(this.state.mapStyle, activeStyle)
|
||||
this.saveStyle(activeStyle)
|
||||
this.onStyleChanged(activeStyle, {addRevision: false});
|
||||
this.setState({
|
||||
mapStyle: activeStyle,
|
||||
infos: messages,
|
||||
})
|
||||
}
|
||||
|
||||
onRedo() {
|
||||
onRedo = () => {
|
||||
const activeStyle = this.revisionStore.redo()
|
||||
const messages = redoMessages(this.state.mapStyle, activeStyle)
|
||||
this.saveStyle(activeStyle)
|
||||
this.onStyleChanged(activeStyle, {addRevision: false});
|
||||
this.setState({
|
||||
mapStyle: activeStyle,
|
||||
infos: messages,
|
||||
})
|
||||
}
|
||||
|
||||
onLayersChange(changedLayers) {
|
||||
onMoveLayer = (move) => {
|
||||
let { oldIndex, newIndex } = move;
|
||||
let layers = this.state.mapStyle.layers;
|
||||
oldIndex = clamp(oldIndex, 0, layers.length-1);
|
||||
newIndex = clamp(newIndex, 0, layers.length-1);
|
||||
if(oldIndex === newIndex) return;
|
||||
|
||||
if (oldIndex === this.state.selectedLayerIndex) {
|
||||
this.setState({
|
||||
selectedLayerIndex: newIndex
|
||||
});
|
||||
}
|
||||
|
||||
layers = layers.slice(0);
|
||||
layers = arrayMove(layers, oldIndex, newIndex);
|
||||
this.onLayersChange(layers);
|
||||
}
|
||||
|
||||
onLayersChange = (changedLayers) => {
|
||||
const changedStyle = {
|
||||
...this.state.mapStyle,
|
||||
layers: changedLayers
|
||||
@@ -159,32 +473,81 @@ export default class App extends React.Component {
|
||||
this.onStyleChanged(changedStyle)
|
||||
}
|
||||
|
||||
onLayerIdChange(oldId, newId) {
|
||||
const changedLayers = this.state.mapStyle.layers.slice(0)
|
||||
const idx = style.indexOfLayer(changedLayers, oldId)
|
||||
onLayerDestroy = (index) => {
|
||||
let layers = this.state.mapStyle.layers;
|
||||
const remainingLayers = layers.slice(0);
|
||||
remainingLayers.splice(index, 1);
|
||||
this.onLayersChange(remainingLayers);
|
||||
}
|
||||
|
||||
changedLayers[idx] = {
|
||||
...changedLayers[idx],
|
||||
onLayerCopy = (index) => {
|
||||
let layers = this.state.mapStyle.layers;
|
||||
const changedLayers = layers.slice(0)
|
||||
|
||||
const clonedLayer = cloneDeep(changedLayers[index])
|
||||
clonedLayer.id = clonedLayer.id + "-copy"
|
||||
changedLayers.splice(index, 0, clonedLayer)
|
||||
this.onLayersChange(changedLayers)
|
||||
}
|
||||
|
||||
onLayerVisibilityToggle = (index) => {
|
||||
let layers = this.state.mapStyle.layers;
|
||||
const changedLayers = layers.slice(0)
|
||||
|
||||
const layer = { ...changedLayers[index] }
|
||||
const changedLayout = 'layout' in layer ? {...layer.layout} : {}
|
||||
changedLayout.visibility = changedLayout.visibility === 'none' ? 'visible' : 'none'
|
||||
|
||||
layer.layout = changedLayout
|
||||
changedLayers[index] = layer
|
||||
this.onLayersChange(changedLayers)
|
||||
}
|
||||
|
||||
|
||||
onLayerIdChange = (index, oldId, newId) => {
|
||||
const changedLayers = this.state.mapStyle.layers.slice(0)
|
||||
changedLayers[index] = {
|
||||
...changedLayers[index],
|
||||
id: newId
|
||||
}
|
||||
|
||||
this.onLayersChange(changedLayers)
|
||||
}
|
||||
|
||||
onLayerChanged(layer) {
|
||||
onLayerChanged = (index, layer) => {
|
||||
const changedLayers = this.state.mapStyle.layers.slice(0)
|
||||
const idx = style.indexOfLayer(changedLayers, layer.id)
|
||||
changedLayers[idx] = layer
|
||||
changedLayers[index] = layer
|
||||
|
||||
this.onLayersChange(changedLayers)
|
||||
}
|
||||
|
||||
changeInspectMode() {
|
||||
setMapState = (newState) => {
|
||||
this.setState({
|
||||
inspectModeEnabled: !this.state.inspectModeEnabled
|
||||
mapState: newState
|
||||
})
|
||||
}
|
||||
|
||||
setDefaultValues = (styleObj) => {
|
||||
const metadata = styleObj.metadata || {}
|
||||
if(metadata['maputnik:renderer'] === undefined) {
|
||||
const changedStyle = {
|
||||
...styleObj,
|
||||
metadata: {
|
||||
...styleObj.metadata,
|
||||
'maputnik:renderer': 'mbgljs'
|
||||
}
|
||||
}
|
||||
return changedStyle
|
||||
} else {
|
||||
return styleObj
|
||||
}
|
||||
}
|
||||
|
||||
openStyle = (styleObj) => {
|
||||
styleObj = this.setDefaultValues(styleObj)
|
||||
this.onStyleChanged(styleObj)
|
||||
}
|
||||
|
||||
fetchSources() {
|
||||
const sourceList = {...this.state.sources};
|
||||
|
||||
@@ -198,13 +561,31 @@ export default class App extends React.Component {
|
||||
layers: []
|
||||
};
|
||||
|
||||
if(!this.state.sources.hasOwnProperty(key) && val.type === "vector") {
|
||||
const url = val.url;
|
||||
fetch(url)
|
||||
if(!this.state.sources.hasOwnProperty(key) && val.type === "vector" && val.hasOwnProperty("url")) {
|
||||
let url = val.url;
|
||||
try {
|
||||
url = normalizeSourceURL(url, MapboxGl.accessToken);
|
||||
} catch(err) {
|
||||
console.warn("Failed to normalizeSourceURL: ", err);
|
||||
}
|
||||
|
||||
try {
|
||||
url = setFetchAccessToken(url, this.state.mapStyle)
|
||||
} catch(err) {
|
||||
console.warn("Failed to setFetchAccessToken: ", err);
|
||||
}
|
||||
|
||||
fetch(url, {
|
||||
mode: 'cors',
|
||||
})
|
||||
.then((response) => {
|
||||
return response.json();
|
||||
})
|
||||
.then((json) => {
|
||||
if(!json.hasOwnProperty("vector_layers")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new objects before setState
|
||||
const sources = Object.assign({}, this.state.sources);
|
||||
|
||||
@@ -231,32 +612,106 @@ export default class App extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
_getRenderer () {
|
||||
const metadata = this.state.mapStyle.metadata || {};
|
||||
return metadata['maputnik:renderer'] || 'mbgljs';
|
||||
}
|
||||
|
||||
onMapChange = (mapView) => {
|
||||
this.setState({
|
||||
mapView,
|
||||
});
|
||||
}
|
||||
|
||||
mapRenderer() {
|
||||
const {mapStyle, dirtyMapStyle} = this.state;
|
||||
const metadata = this.state.mapStyle.metadata || {};
|
||||
|
||||
const mapProps = {
|
||||
mapStyle: style.replaceAccessToken(this.state.mapStyle),
|
||||
mapStyle: (dirtyMapStyle || mapStyle),
|
||||
replaceAccessTokens: (mapStyle) => {
|
||||
return style.replaceAccessTokens(mapStyle, {
|
||||
allowFallback: true
|
||||
});
|
||||
},
|
||||
onDataChange: (e) => {
|
||||
this.layerWatcher.analyzeMap(e.map)
|
||||
this.fetchSources();
|
||||
},
|
||||
}
|
||||
|
||||
const metadata = this.state.mapStyle.metadata || {}
|
||||
const renderer = metadata['maputnik:renderer'] || 'mbgljs'
|
||||
const renderer = this._getRenderer();
|
||||
|
||||
// Check if OL3 code has been loaded?
|
||||
if(renderer === 'ol3') {
|
||||
return <OpenLayers3Map {...mapProps} />
|
||||
let mapElement;
|
||||
|
||||
// Check if OL code has been loaded?
|
||||
if(renderer === 'ol') {
|
||||
mapElement = <OpenLayersMap
|
||||
{...mapProps}
|
||||
onChange={this.onMapChange}
|
||||
debugToolbox={this.state.openlayersDebugOptions.debugToolbox}
|
||||
onLayerSelect={this.onLayerSelect}
|
||||
/>
|
||||
} else {
|
||||
return <MapboxGlMap {...mapProps}
|
||||
inspectModeEnabled={this.state.inspectModeEnabled}
|
||||
mapElement = <MapboxGlMap {...mapProps}
|
||||
onChange={this.onMapChange}
|
||||
options={this.state.mapboxGlDebugOptions}
|
||||
inspectModeEnabled={this.state.mapState === "inspect"}
|
||||
highlightedLayer={this.state.mapStyle.layers[this.state.selectedLayerIndex]}
|
||||
onLayerSelect={this.onLayerSelect.bind(this)} />
|
||||
onLayerSelect={this.onLayerSelect} />
|
||||
}
|
||||
|
||||
let filterName;
|
||||
if(this.state.mapState.match(/^filter-/)) {
|
||||
filterName = this.state.mapState.replace(/^filter-/, "");
|
||||
}
|
||||
const elementStyle = {};
|
||||
if (filterName) {
|
||||
elementStyle.filter = `url('#${filterName}')`;
|
||||
};
|
||||
|
||||
return <div style={elementStyle} className="maputnik-map__container">
|
||||
{mapElement}
|
||||
</div>
|
||||
}
|
||||
|
||||
onLayerSelect(layerId) {
|
||||
const idx = style.indexOfLayer(this.state.mapStyle.layers, layerId)
|
||||
this.setState({ selectedLayerIndex: idx })
|
||||
onLayerSelect = (index) => {
|
||||
this.setState({ selectedLayerIndex: index })
|
||||
}
|
||||
|
||||
setModal(modalName, value) {
|
||||
if(modalName === 'survey' && value === false) {
|
||||
localStorage.setItem('survey', '');
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isOpen: {
|
||||
...this.state.isOpen,
|
||||
[modalName]: value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
toggleModal(modalName) {
|
||||
this.setModal(modalName, !this.state.isOpen[modalName]);
|
||||
}
|
||||
|
||||
onChangeOpenlayersDebug = (key, value) => {
|
||||
this.setState({
|
||||
openlayersDebugOptions: {
|
||||
...this.state.openlayersDebugOptions,
|
||||
[key]: value,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onChangeMaboxGlDebug = (key, value) => {
|
||||
this.setState({
|
||||
mapboxGlDebugOptions: {
|
||||
...this.state.mapboxGlDebugOptions,
|
||||
[key]: value,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -265,42 +720,112 @@ export default class App extends React.Component {
|
||||
const metadata = this.state.mapStyle.metadata || {}
|
||||
|
||||
const toolbar = <Toolbar
|
||||
renderer={this._getRenderer()}
|
||||
mapState={this.state.mapState}
|
||||
mapStyle={this.state.mapStyle}
|
||||
inspectModeEnabled={this.state.inspectModeEnabled}
|
||||
inspectModeEnabled={this.state.mapState === "inspect"}
|
||||
sources={this.state.sources}
|
||||
onStyleChanged={this.onStyleChanged.bind(this)}
|
||||
onStyleOpen={this.onStyleChanged.bind(this)}
|
||||
onInspectModeToggle={this.changeInspectMode.bind(this)}
|
||||
onStyleChanged={this.onStyleChanged}
|
||||
onStyleOpen={this.onStyleChanged}
|
||||
onSetMapState={this.setMapState}
|
||||
onToggleModal={this.toggleModal.bind(this)}
|
||||
/>
|
||||
|
||||
const layerList = <LayerList
|
||||
onLayersChange={this.onLayersChange.bind(this)}
|
||||
onLayerSelect={this.onLayerSelect.bind(this)}
|
||||
onMoveLayer={this.onMoveLayer}
|
||||
onLayerDestroy={this.onLayerDestroy}
|
||||
onLayerCopy={this.onLayerCopy}
|
||||
onLayerVisibilityToggle={this.onLayerVisibilityToggle}
|
||||
onLayersChange={this.onLayersChange}
|
||||
onLayerSelect={this.onLayerSelect}
|
||||
selectedLayerIndex={this.state.selectedLayerIndex}
|
||||
layers={layers}
|
||||
sources={this.state.sources}
|
||||
errors={this.state.errors}
|
||||
/>
|
||||
|
||||
const layerEditor = selectedLayer ? <LayerEditor
|
||||
key={selectedLayer.id}
|
||||
layer={selectedLayer}
|
||||
layerIndex={this.state.selectedLayerIndex}
|
||||
isFirstLayer={this.state.selectedLayerIndex < 1}
|
||||
isLastLayer={this.state.selectedLayerIndex === this.state.mapStyle.layers.length-1}
|
||||
sources={this.state.sources}
|
||||
vectorLayers={this.state.vectorLayers}
|
||||
spec={this.state.spec}
|
||||
onLayerChanged={this.onLayerChanged.bind(this)}
|
||||
onLayerIdChange={this.onLayerIdChange.bind(this)}
|
||||
onMoveLayer={this.onMoveLayer}
|
||||
onLayerChanged={this.onLayerChanged}
|
||||
onLayerDestroy={this.onLayerDestroy}
|
||||
onLayerCopy={this.onLayerCopy}
|
||||
onLayerVisibilityToggle={this.onLayerVisibilityToggle}
|
||||
onLayerIdChange={this.onLayerIdChange}
|
||||
errors={this.state.errors}
|
||||
/> : null
|
||||
|
||||
const bottomPanel = (this.state.errors.length + this.state.infos.length) > 0 ? <MessagePanel
|
||||
currentLayer={selectedLayer}
|
||||
selectedLayerIndex={this.state.selectedLayerIndex}
|
||||
onLayerSelect={this.onLayerSelect}
|
||||
mapStyle={this.state.mapStyle}
|
||||
errors={this.state.errors}
|
||||
infos={this.state.infos}
|
||||
/> : null
|
||||
|
||||
|
||||
const modals = <div>
|
||||
<DebugModal
|
||||
renderer={this._getRenderer()}
|
||||
mapboxGlDebugOptions={this.state.mapboxGlDebugOptions}
|
||||
openlayersDebugOptions={this.state.openlayersDebugOptions}
|
||||
onChangeMaboxGlDebug={this.onChangeMaboxGlDebug}
|
||||
onChangeOpenlayersDebug={this.onChangeOpenlayersDebug}
|
||||
isOpen={this.state.isOpen.debug}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'debug')}
|
||||
mapView={this.state.mapView}
|
||||
/>
|
||||
<ShortcutsModal
|
||||
ref={(el) => this.shortcutEl = el}
|
||||
isOpen={this.state.isOpen.shortcuts}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'shortcuts')}
|
||||
/>
|
||||
<SettingsModal
|
||||
mapStyle={this.state.mapStyle}
|
||||
onStyleChanged={this.onStyleChanged}
|
||||
onChangeMetadataProperty={this.onChangeMetadataProperty}
|
||||
isOpen={this.state.isOpen.settings}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'settings')}
|
||||
openlayersDebugOptions={this.state.openlayersDebugOptions}
|
||||
/>
|
||||
<ExportModal
|
||||
mapStyle={this.state.mapStyle}
|
||||
onStyleChanged={this.onStyleChanged}
|
||||
isOpen={this.state.isOpen.export}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'export')}
|
||||
/>
|
||||
<OpenModal
|
||||
isOpen={this.state.isOpen.open}
|
||||
onStyleOpen={this.openStyle}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'open')}
|
||||
/>
|
||||
<SourcesModal
|
||||
mapStyle={this.state.mapStyle}
|
||||
onStyleChanged={this.onStyleChanged}
|
||||
isOpen={this.state.isOpen.sources}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'sources')}
|
||||
/>
|
||||
<SurveyModal
|
||||
isOpen={this.state.isOpen.survey}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'survey')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
return <AppLayout
|
||||
toolbar={toolbar}
|
||||
layerList={layerList}
|
||||
layerEditor={layerEditor}
|
||||
map={this.mapRenderer()}
|
||||
bottom={bottomPanel}
|
||||
modals={modals}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ class AppLayout extends React.Component {
|
||||
layerEditor: PropTypes.element,
|
||||
map: PropTypes.element.isRequired,
|
||||
bottom: PropTypes.element,
|
||||
modals: PropTypes.node,
|
||||
}
|
||||
|
||||
static childContextTypes = {
|
||||
@@ -25,9 +26,7 @@ class AppLayout extends React.Component {
|
||||
return <div className="maputnik-layout">
|
||||
{this.props.toolbar}
|
||||
<div className="maputnik-layout-list">
|
||||
<ScrollContainer>
|
||||
{this.props.layerList}
|
||||
</ScrollContainer>
|
||||
{this.props.layerList}
|
||||
</div>
|
||||
<div className="maputnik-layout-drawer">
|
||||
<ScrollContainer>
|
||||
@@ -39,6 +38,7 @@ class AppLayout extends React.Component {
|
||||
{this.props.bottom}
|
||||
</div>
|
||||
}
|
||||
{this.props.modals}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,26 @@ import classnames from 'classnames'
|
||||
|
||||
class Button extends React.Component {
|
||||
static propTypes = {
|
||||
"data-wd-key": PropTypes.string,
|
||||
"aria-label": PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
style: PropTypes.object,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node
|
||||
children: PropTypes.node,
|
||||
disabled: PropTypes.bool,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <a
|
||||
return <button
|
||||
onClick={this.props.onClick}
|
||||
disabled={this.props.disabled}
|
||||
aria-label={this.props["aria-label"]}
|
||||
className={classnames("maputnik-button", this.props.className)}
|
||||
style={this.props.style}>
|
||||
data-wd-key={this.props["data-wd-key"]}
|
||||
style={this.props.style}
|
||||
>
|
||||
{this.props.children}
|
||||
</a>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,52 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {formatLayerId} from './util/format';
|
||||
|
||||
class MessagePanel extends React.Component {
|
||||
static propTypes = {
|
||||
errors: PropTypes.array,
|
||||
infos: PropTypes.array,
|
||||
mapStyle: PropTypes.object,
|
||||
onLayerSelect: PropTypes.func,
|
||||
currentLayer: PropTypes.object,
|
||||
selectedLayerIndex: PropTypes.number,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onLayerSelect: () => {},
|
||||
}
|
||||
|
||||
render() {
|
||||
const errors = this.props.errors.map((m, i) => {
|
||||
return <p key={"error-"+i} className="maputnik-message-panel-error">{m}</p>
|
||||
const {selectedLayerIndex} = this.props;
|
||||
const errors = this.props.errors.map((error, idx) => {
|
||||
let content;
|
||||
if (error.parsed && error.parsed.type === "layer") {
|
||||
const {parsed} = error;
|
||||
const {mapStyle, currentLayer} = this.props;
|
||||
const layerId = mapStyle.layers[parsed.data.index].id;
|
||||
content = (
|
||||
<>
|
||||
Layer <span>{formatLayerId(layerId)}</span>: {parsed.data.message}
|
||||
{selectedLayerIndex !== parsed.data.index &&
|
||||
<>
|
||||
—
|
||||
<button
|
||||
className="maputnik-message-panel__switch-button"
|
||||
onClick={() => this.props.onLayerSelect(parsed.data.index)}
|
||||
>
|
||||
switch to layer
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
else {
|
||||
content = error.message;
|
||||
}
|
||||
return <p key={"error-"+idx} className="maputnik-message-panel-error">
|
||||
{content}
|
||||
</p>
|
||||
})
|
||||
|
||||
const infos = this.props.infos.map((m, i) => {
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import FileReaderInput from 'react-file-reader-input'
|
||||
import classnames from 'classnames'
|
||||
import {detect} from 'detect-browser';
|
||||
|
||||
import {MdFileDownload, MdOpenInBrowser, MdSettings, MdLayers, MdHelpOutline, MdFindInPage, MdAssignmentTurnedIn} from 'react-icons/md'
|
||||
|
||||
import MdFileDownload from 'react-icons/lib/md/file-download'
|
||||
import MdFileUpload from 'react-icons/lib/md/file-upload'
|
||||
import OpenIcon from 'react-icons/lib/md/open-in-browser'
|
||||
import SettingsIcon from 'react-icons/lib/md/settings'
|
||||
import MdInfo from 'react-icons/lib/md/info'
|
||||
import SourcesIcon from 'react-icons/lib/md/layers'
|
||||
import MdSave from 'react-icons/lib/md/save'
|
||||
import MdStyle from 'react-icons/lib/md/style'
|
||||
import MdMap from 'react-icons/lib/md/map'
|
||||
import MdInsertEmoticon from 'react-icons/lib/md/insert-emoticon'
|
||||
import MdFontDownload from 'react-icons/lib/md/font-download'
|
||||
import HelpIcon from 'react-icons/lib/md/help-outline'
|
||||
import InspectionIcon from 'react-icons/lib/md/find-in-page'
|
||||
|
||||
import logoImage from 'maputnik-design/logos/logo-color.svg'
|
||||
import SettingsModal from './modals/SettingsModal'
|
||||
import ExportModal from './modals/ExportModal'
|
||||
import SourcesModal from './modals/SourcesModal'
|
||||
import OpenModal from './modals/OpenModal'
|
||||
import pkgJson from '../../package.json'
|
||||
|
||||
|
||||
// This is required because of <https://stackoverflow.com/a/49846426>, there isn't another way to detect support that I'm aware of.
|
||||
const browser = detect();
|
||||
const colorAccessibilityFiltersEnabled = ['chrome', 'firefox'].indexOf(browser.name) > -1;
|
||||
|
||||
import style from '../libs/style'
|
||||
|
||||
class IconText extends React.Component {
|
||||
static propTypes = {
|
||||
@@ -40,6 +30,7 @@ class ToolbarLink extends React.Component {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
href: PropTypes.string,
|
||||
onToggleModal: PropTypes.func,
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -54,19 +45,59 @@ class ToolbarLink extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarAction extends React.Component {
|
||||
class ToolbarLinkHighlighted extends React.Component {
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
onClick: PropTypes.func
|
||||
href: PropTypes.string,
|
||||
onToggleModal: PropTypes.func
|
||||
}
|
||||
|
||||
render() {
|
||||
return <a
|
||||
className={classnames('maputnik-toolbar-link', "maputnik-toolbar-link--highlighted", this.props.className)}
|
||||
href={this.props.href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="maputnik-toolbar-link-wrapper">
|
||||
{this.props.children}
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarSelect extends React.Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
wdKey: PropTypes.string
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div
|
||||
className='maputnik-toolbar-select'
|
||||
data-wd-key={this.props.wdKey}
|
||||
>
|
||||
{this.props.children}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarAction extends React.Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
onClick: PropTypes.func,
|
||||
wdKey: PropTypes.string
|
||||
}
|
||||
|
||||
render() {
|
||||
return <button
|
||||
className='maputnik-toolbar-action'
|
||||
data-wd-key={this.props.wdKey}
|
||||
onClick={this.props.onClick}
|
||||
>
|
||||
{this.props.children}
|
||||
</a>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,93 +110,125 @@ export default class Toolbar extends React.Component {
|
||||
onStyleOpen: PropTypes.func.isRequired,
|
||||
// A dict of source id's and the available source layers
|
||||
sources: PropTypes.object.isRequired,
|
||||
onInspectModeToggle: PropTypes.func.isRequired,
|
||||
children: PropTypes.node
|
||||
children: PropTypes.node,
|
||||
onToggleModal: PropTypes.func,
|
||||
onSetMapState: PropTypes.func,
|
||||
mapState: PropTypes.string,
|
||||
renderer: PropTypes.string,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
isOpen: {
|
||||
settings: false,
|
||||
sources: false,
|
||||
open: false,
|
||||
add: false,
|
||||
export: false,
|
||||
}
|
||||
state = {
|
||||
isOpen: {
|
||||
settings: false,
|
||||
sources: false,
|
||||
open: false,
|
||||
add: false,
|
||||
export: false,
|
||||
}
|
||||
}
|
||||
|
||||
toggleModal(modalName) {
|
||||
this.setState({
|
||||
isOpen: {
|
||||
...this.state.isOpen,
|
||||
[modalName]: !this.state.isOpen[modalName]
|
||||
}
|
||||
})
|
||||
handleSelection(val) {
|
||||
this.props.onSetMapState(val);
|
||||
}
|
||||
|
||||
render() {
|
||||
const views = [
|
||||
{
|
||||
id: "map",
|
||||
title: "Map",
|
||||
},
|
||||
{
|
||||
id: "inspect",
|
||||
title: "Inspect",
|
||||
disabled: this.props.renderer !== 'mbgljs',
|
||||
},
|
||||
{
|
||||
id: "filter-deuteranopia",
|
||||
title: "Map (deuteranopia)",
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
{
|
||||
id: "filter-protanopia",
|
||||
title: "Map (protanopia)",
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
{
|
||||
id: "filter-tritanopia",
|
||||
title: "Map (tritanopia)",
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
{
|
||||
id: "filter-achromatopsia",
|
||||
title: "Map (achromatopsia)",
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
];
|
||||
|
||||
const currentView = views.find((view) => {
|
||||
return view.id === this.props.mapState;
|
||||
});
|
||||
|
||||
return <div className='maputnik-toolbar'>
|
||||
<SettingsModal
|
||||
mapStyle={this.props.mapStyle}
|
||||
onStyleChanged={this.props.onStyleChanged}
|
||||
isOpen={this.state.isOpen.settings}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'settings')}
|
||||
/>
|
||||
<ExportModal
|
||||
mapStyle={this.props.mapStyle}
|
||||
onStyleChanged={this.props.onStyleChanged}
|
||||
isOpen={this.state.isOpen.export}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'export')}
|
||||
/>
|
||||
<OpenModal
|
||||
isOpen={this.state.isOpen.open}
|
||||
onStyleOpen={this.props.onStyleOpen}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'open')}
|
||||
/>
|
||||
<SourcesModal
|
||||
mapStyle={this.props.mapStyle}
|
||||
onStyleChanged={this.props.onStyleChanged}
|
||||
isOpen={this.state.isOpen.sources}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'sources')}
|
||||
/>
|
||||
<div className="maputnik-toolbar__inner">
|
||||
<ToolbarLink
|
||||
href={"https://github.com/maputnik/editor"}
|
||||
className="maputnik-toolbar-logo"
|
||||
<div
|
||||
className="maputnik-toolbar-logo-container"
|
||||
>
|
||||
<img src={logoImage} alt="Maputnik" />
|
||||
<h1>Maputnik</h1>
|
||||
</ToolbarLink>
|
||||
<a className="maputnik-toolbar-skip" href="#skip-menu">
|
||||
Skip navigation
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/maputnik/editor"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="maputnik-toolbar-logo"
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{__html: logoImage}} />
|
||||
<h1>
|
||||
<span className="maputnik-toolbar-name">{pkgJson.name}</span>
|
||||
<span className="maputnik-toolbar-version">v{pkgJson.version}</span>
|
||||
</h1>
|
||||
</a>
|
||||
</div>
|
||||
<div className="maputnik-toolbar__actions">
|
||||
<ToolbarAction onClick={this.toggleModal.bind(this, 'open')}>
|
||||
<OpenIcon />
|
||||
<ToolbarAction wdKey="nav:open" onClick={this.props.onToggleModal.bind(this, 'open')}>
|
||||
<MdOpenInBrowser />
|
||||
<IconText>Open</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction onClick={this.toggleModal.bind(this, 'export')}>
|
||||
<ToolbarAction wdKey="nav:export" onClick={this.props.onToggleModal.bind(this, 'export')}>
|
||||
<MdFileDownload />
|
||||
<IconText>Export</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction onClick={this.toggleModal.bind(this, 'sources')}>
|
||||
<SourcesIcon />
|
||||
<IconText>Sources</IconText>
|
||||
<ToolbarAction wdKey="nav:sources" onClick={this.props.onToggleModal.bind(this, 'sources')}>
|
||||
<MdLayers />
|
||||
<IconText>Data Sources</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction onClick={this.toggleModal.bind(this, 'settings')}>
|
||||
<SettingsIcon />
|
||||
<ToolbarAction wdKey="nav:settings" onClick={this.props.onToggleModal.bind(this, 'settings')}>
|
||||
<MdSettings />
|
||||
<IconText>Style Settings</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction onClick={this.props.onInspectModeToggle}>
|
||||
<InspectionIcon />
|
||||
<IconText>
|
||||
{ this.props.inspectModeEnabled && <span>Map Mode</span> }
|
||||
{ !this.props.inspectModeEnabled && <span>Inspect Mode</span> }
|
||||
</IconText>
|
||||
</ToolbarAction>
|
||||
|
||||
<ToolbarSelect wdKey="nav:inspect">
|
||||
<MdFindInPage />
|
||||
<IconText>View </IconText>
|
||||
<select onChange={(e) => this.handleSelection(e.target.value)} value={currentView.id}>
|
||||
{views.map((item) => {
|
||||
return (
|
||||
<option key={item.id} value={item.id} disabled={item.disabled}>
|
||||
{item.title}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</ToolbarSelect>
|
||||
|
||||
<ToolbarLink href={"https://github.com/maputnik/editor/wiki"}>
|
||||
<HelpIcon />
|
||||
<MdHelpOutline />
|
||||
<IconText>Help</IconText>
|
||||
</ToolbarLink>
|
||||
<ToolbarLinkHighlighted href={"https://gregorywolanski.typeform.com/to/cPgaSY"}>
|
||||
<MdAssignmentTurnedIn />
|
||||
<IconText>Take the Maputnik Survey</IconText>
|
||||
</ToolbarLinkHighlighted>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import Color from 'color'
|
||||
import ChromePicker from 'react-color/lib/components/chrome/Chrome'
|
||||
import PropTypes from 'prop-types'
|
||||
import lodash from 'lodash';
|
||||
|
||||
function formatColor(color) {
|
||||
const rgb = color.rgb
|
||||
@@ -12,24 +13,30 @@ function formatColor(color) {
|
||||
class ColorField extends React.Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
name: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
doc: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
default: PropTypes.string,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
pickerOpened: false,
|
||||
}
|
||||
state = {
|
||||
pickerOpened: false
|
||||
}
|
||||
|
||||
constructor () {
|
||||
super();
|
||||
this.onChangeNoCheck = lodash.throttle(this.onChangeNoCheck, 1000/30);
|
||||
}
|
||||
|
||||
onChangeNoCheck (v) {
|
||||
this.props.onChange(v);
|
||||
}
|
||||
|
||||
//TODO: I much rather would do this with absolute positioning
|
||||
//but I am too stupid to get it to work together with fixed position
|
||||
//and scrollbars so I have to fallback to JavaScript
|
||||
calcPickerOffset() {
|
||||
calcPickerOffset = () => {
|
||||
const elem = this.colorInput
|
||||
if(elem) {
|
||||
const pos = elem.getBoundingClientRect()
|
||||
@@ -45,7 +52,7 @@ class ColorField extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
togglePicker() {
|
||||
togglePicker = () => {
|
||||
this.setState({ pickerOpened: !this.state.pickerOpened })
|
||||
}
|
||||
|
||||
@@ -60,6 +67,10 @@ class ColorField extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
onChange (v) {
|
||||
this.props.onChange(v === "" ? undefined : v);
|
||||
}
|
||||
|
||||
render() {
|
||||
const offset = this.calcPickerOffset()
|
||||
var currentColor = this.color.object()
|
||||
@@ -81,11 +92,11 @@ class ColorField extends React.Component {
|
||||
}}>
|
||||
<ChromePicker
|
||||
color={currentColor}
|
||||
onChange={c => this.props.onChange(formatColor(c))}
|
||||
onChange={c => this.onChangeNoCheck(formatColor(c))}
|
||||
/>
|
||||
<div
|
||||
className="maputnik-color-picker-offset"
|
||||
onClick={this.togglePicker.bind(this)}
|
||||
onClick={this.togglePicker}
|
||||
style={{
|
||||
zIndex: -1,
|
||||
position: 'fixed',
|
||||
@@ -105,14 +116,15 @@ class ColorField extends React.Component {
|
||||
{this.state.pickerOpened && picker}
|
||||
<div className="maputnik-color-swatch" style={swatchStyle}></div>
|
||||
<input
|
||||
spellCheck="false"
|
||||
className="maputnik-color"
|
||||
ref={(input) => this.colorInput = input}
|
||||
onClick={this.togglePicker.bind(this)}
|
||||
onClick={this.togglePicker}
|
||||
style={this.props.style}
|
||||
name={this.props.name}
|
||||
placeholder={this.props.default}
|
||||
value={this.props.value ? this.props.value : ""}
|
||||
onChange={(e) => this.props.onChange(e.target.value)}
|
||||
onChange={(e) => this.onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,23 +1,63 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import {MdInfoOutline, MdHighlightOff} from 'react-icons/md'
|
||||
|
||||
export default class DocLabel extends React.Component {
|
||||
static propTypes = {
|
||||
label: PropTypes.oneOfType([
|
||||
PropTypes.object,
|
||||
PropTypes.string
|
||||
]).isRequired,
|
||||
doc: PropTypes.string.isRequired,
|
||||
fieldSpec: PropTypes.object,
|
||||
onToggleDoc: PropTypes.func,
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
open: false,
|
||||
}
|
||||
}
|
||||
|
||||
onToggleDoc = (open) => {
|
||||
this.setState({
|
||||
open,
|
||||
}, () => {
|
||||
if (this.props.onToggleDoc) {
|
||||
this.props.onToggleDoc(this.state.open);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return <label className="maputnik-doc-wrapper">
|
||||
<div className="maputnik-doc-target">
|
||||
<span>{this.props.label}</span>
|
||||
<div className="maputnik-doc-popup">
|
||||
{this.props.doc}
|
||||
const {label, fieldSpec} = this.props;
|
||||
const {doc} = fieldSpec || {};
|
||||
|
||||
if (doc) {
|
||||
return <label className="maputnik-doc-wrapper">
|
||||
<div className="maputnik-doc-target">
|
||||
{label}
|
||||
{'\xa0'}
|
||||
<button
|
||||
aria-label={this.state.open ? "close property documentation" : "open property documentation"}
|
||||
className={`maputnik-doc-button maputnik-doc-button--${this.state.open ? 'open' : 'closed'}`}
|
||||
onClick={() => this.onToggleDoc(!this.state.open)}
|
||||
>
|
||||
{this.state.open ? <MdHighlightOff /> : <MdInfoOutline />}
|
||||
</button>
|
||||
</div>
|
||||
</div >
|
||||
</label>
|
||||
</label>
|
||||
}
|
||||
else if (label) {
|
||||
return <label className="maputnik-doc-wrapper">
|
||||
<div className="maputnik-doc-target">
|
||||
{label}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
else {
|
||||
<div />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,97 @@ import PropTypes from 'prop-types'
|
||||
import SpecProperty from './_SpecProperty'
|
||||
import DataProperty from './_DataProperty'
|
||||
import ZoomProperty from './_ZoomProperty'
|
||||
import ExpressionProperty from './_ExpressionProperty'
|
||||
import {function as styleFunction} from '@mapbox/mapbox-gl-style-spec';
|
||||
|
||||
|
||||
function isLiteralExpression (value) {
|
||||
return (Array.isArray(value) && value.length === 2 && value[0] === "literal");
|
||||
}
|
||||
|
||||
function isZoomField(value) {
|
||||
return typeof value === 'object' && value.stops && typeof value.property === 'undefined'
|
||||
return (
|
||||
typeof(value) === 'object' &&
|
||||
value.stops &&
|
||||
typeof(value.property) === 'undefined' &&
|
||||
Array.isArray(value.stops) &&
|
||||
value.stops.length > 1 &&
|
||||
value.stops.every(stop => {
|
||||
return (
|
||||
Array.isArray(stop) &&
|
||||
stop.length === 2
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isDataField(value) {
|
||||
return typeof value === 'object' && value.stops && typeof value.property !== 'undefined'
|
||||
return (
|
||||
typeof(value) === 'object' &&
|
||||
value.stops &&
|
||||
typeof(value.property) !== 'undefined' &&
|
||||
value.stops.length > 1 &&
|
||||
Array.isArray(value.stops) &&
|
||||
value.stops.every(stop => {
|
||||
return (
|
||||
Array.isArray(stop) &&
|
||||
stop.length === 2 &&
|
||||
typeof(stop[0]) === 'object'
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isPrimative (value) {
|
||||
const valid = ["string", "boolean", "number"];
|
||||
return valid.includes(typeof(value));
|
||||
}
|
||||
|
||||
function isArrayOfPrimatives (values) {
|
||||
if (Array.isArray(values)) {
|
||||
return values.every(isPrimative);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getDataType (value, fieldSpec={}) {
|
||||
if (value === undefined) {
|
||||
return "value";
|
||||
}
|
||||
else if (isPrimative(value)) {
|
||||
return "value";
|
||||
}
|
||||
else if (fieldSpec.type === "array" && isArrayOfPrimatives(value)) {
|
||||
return "value";
|
||||
}
|
||||
else if (isZoomField(value)) {
|
||||
return "zoom_function";
|
||||
}
|
||||
else if (isDataField(value)) {
|
||||
return "data_function";
|
||||
}
|
||||
else {
|
||||
return "expression";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If we don't have a default value just make one up
|
||||
*/
|
||||
function findDefaultFromSpec (spec) {
|
||||
if (spec.hasOwnProperty('default')) {
|
||||
return spec.default;
|
||||
}
|
||||
|
||||
const defaults = {
|
||||
'color': '#000000',
|
||||
'string': '',
|
||||
'boolean': false,
|
||||
'number': 0,
|
||||
'array': [],
|
||||
}
|
||||
|
||||
return defaults[spec.type] || '';
|
||||
}
|
||||
|
||||
/** Supports displaying spec field for zoom function objects
|
||||
@@ -21,7 +104,9 @@ export default class FunctionSpecProperty extends React.Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
fieldName: PropTypes.string.isRequired,
|
||||
fieldType: PropTypes.string.isRequired,
|
||||
fieldSpec: PropTypes.object.isRequired,
|
||||
errors: PropTypes.object,
|
||||
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.object,
|
||||
@@ -32,7 +117,38 @@ export default class FunctionSpecProperty extends React.Component {
|
||||
]),
|
||||
}
|
||||
|
||||
addStop() {
|
||||
constructor (props) {
|
||||
super();
|
||||
this.state = {
|
||||
dataType: getDataType(props.value, props.fieldSpec),
|
||||
isEditing: false,
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
// Because otherwise when editing values we end up accidentally changing field type.
|
||||
if (state.isEditing) {
|
||||
return {};
|
||||
}
|
||||
else {
|
||||
return {
|
||||
isEditing: false,
|
||||
dataType: getDataType(props.value, props.fieldSpec)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getFieldFunctionType(fieldSpec) {
|
||||
if (fieldSpec.expression.interpolated) {
|
||||
return "exponential"
|
||||
}
|
||||
if (fieldSpec.type === "number") {
|
||||
return "interval"
|
||||
}
|
||||
return "categorical"
|
||||
}
|
||||
|
||||
addStop = () => {
|
||||
const stops = this.props.value.stops.slice(0)
|
||||
const lastStop = stops[stops.length - 1]
|
||||
if (typeof lastStop[0] === "object") {
|
||||
@@ -53,7 +169,15 @@ export default class FunctionSpecProperty extends React.Component {
|
||||
this.props.onChange(this.props.fieldName, changedValue)
|
||||
}
|
||||
|
||||
deleteStop(stopIdx) {
|
||||
deleteExpression = () => {
|
||||
const {fieldSpec, fieldName} = this.props;
|
||||
this.props.onChange(fieldName, fieldSpec.default);
|
||||
this.setState({
|
||||
dataType: "value",
|
||||
});
|
||||
}
|
||||
|
||||
deleteStop = (stopIdx) => {
|
||||
const stops = this.props.value.stops.slice(0)
|
||||
stops.splice(stopIdx, 1)
|
||||
|
||||
@@ -69,70 +193,139 @@ export default class FunctionSpecProperty extends React.Component {
|
||||
this.props.onChange(this.props.fieldName, changedValue)
|
||||
}
|
||||
|
||||
makeZoomFunction() {
|
||||
makeZoomFunction = () => {
|
||||
const zoomFunc = {
|
||||
stops: [
|
||||
[6, this.props.value],
|
||||
[10, this.props.value]
|
||||
[6, this.props.value || findDefaultFromSpec(this.props.fieldSpec)],
|
||||
[10, this.props.value || findDefaultFromSpec(this.props.fieldSpec)]
|
||||
]
|
||||
}
|
||||
this.props.onChange(this.props.fieldName, zoomFunc)
|
||||
}
|
||||
|
||||
makeDataFunction() {
|
||||
undoExpression = () => {
|
||||
const {value, fieldName} = this.props;
|
||||
|
||||
if (isLiteralExpression(value)) {
|
||||
this.props.onChange(fieldName, value[1]);
|
||||
this.setState({
|
||||
dataType: "value",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
canUndo = () => {
|
||||
const {value, fieldSpec} = this.props;
|
||||
return (
|
||||
isLiteralExpression(value) ||
|
||||
isPrimative(value) ||
|
||||
(Array.isArray(value) && fieldSpec.type === "array")
|
||||
);
|
||||
}
|
||||
|
||||
makeExpression = () => {
|
||||
const {value, fieldSpec} = this.props;
|
||||
let expression;
|
||||
|
||||
if (typeof(value) === "object" && 'stops' in value) {
|
||||
expression = styleFunction.convertFunction(value, fieldSpec);
|
||||
}
|
||||
else {
|
||||
expression = ["literal", value || this.props.fieldSpec.default];
|
||||
}
|
||||
this.props.onChange(this.props.fieldName, expression);
|
||||
}
|
||||
|
||||
makeDataFunction = () => {
|
||||
const functionType = this.getFieldFunctionType(this.props.fieldSpec);
|
||||
const stopValue = functionType === 'categorical' ? '' : 0;
|
||||
const dataFunc = {
|
||||
property: "",
|
||||
type: "categorical",
|
||||
type: functionType,
|
||||
stops: [
|
||||
[{zoom: 6, value: 0}, this.props.value],
|
||||
[{zoom: 10, value: 0}, this.props.value]
|
||||
[{zoom: 6, value: stopValue}, this.props.value || findDefaultFromSpec(this.props.fieldSpec)],
|
||||
[{zoom: 10, value: stopValue}, this.props.value || findDefaultFromSpec(this.props.fieldSpec)]
|
||||
]
|
||||
}
|
||||
this.props.onChange(this.props.fieldName, dataFunc)
|
||||
}
|
||||
|
||||
onMarkEditing = () => {
|
||||
this.setState({isEditing: true});
|
||||
}
|
||||
|
||||
onUnmarkEditing = () => {
|
||||
this.setState({isEditing: false});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {dataType} = this.state;
|
||||
const propClass = this.props.fieldSpec.default === this.props.value ? "maputnik-default-property" : "maputnik-modified-property"
|
||||
let specField;
|
||||
|
||||
if (isZoomField(this.props.value)) {
|
||||
if (dataType === "expression") {
|
||||
specField = (
|
||||
<ZoomProperty
|
||||
onChange={this.props.onChange.bind(this)}
|
||||
<ExpressionProperty
|
||||
errors={this.props.errors}
|
||||
onChange={this.props.onChange.bind(this, this.props.fieldName)}
|
||||
canUndo={this.canUndo}
|
||||
onUndo={this.undoExpression}
|
||||
onDelete={this.deleteExpression}
|
||||
fieldType={this.props.fieldType}
|
||||
fieldName={this.props.fieldName}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
value={this.props.value}
|
||||
onDeleteStop={this.deleteStop.bind(this)}
|
||||
onAddStop={this.addStop.bind(this)}
|
||||
onFocus={this.onMarkEditing}
|
||||
onBlur={this.onUnmarkEditing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
else if (dataType === "zoom_function") {
|
||||
specField = (
|
||||
<ZoomProperty
|
||||
errors={this.props.errors}
|
||||
onChange={this.props.onChange.bind(this)}
|
||||
fieldType={this.props.fieldType}
|
||||
fieldName={this.props.fieldName}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
value={this.props.value}
|
||||
onDeleteStop={this.deleteStop}
|
||||
onAddStop={this.addStop}
|
||||
onExpressionClick={this.makeExpression}
|
||||
/>
|
||||
)
|
||||
}
|
||||
else if (isDataField(this.props.value)) {
|
||||
else if (dataType === "data_function") {
|
||||
specField = (
|
||||
<DataProperty
|
||||
errors={this.props.errors}
|
||||
onChange={this.props.onChange.bind(this)}
|
||||
fieldType={this.props.fieldType}
|
||||
fieldName={this.props.fieldName}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
value={this.props.value}
|
||||
onDeleteStop={this.deleteStop.bind(this)}
|
||||
onAddStop={this.addStop.bind(this)}
|
||||
onDeleteStop={this.deleteStop}
|
||||
onAddStop={this.addStop}
|
||||
onExpressionClick={this.makeExpression}
|
||||
/>
|
||||
)
|
||||
}
|
||||
else {
|
||||
specField = (
|
||||
<SpecProperty
|
||||
errors={this.props.errors}
|
||||
onChange={this.props.onChange.bind(this)}
|
||||
fieldType={this.props.fieldType}
|
||||
fieldName={this.props.fieldName}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
value={this.props.value}
|
||||
onZoomClick={this.makeZoomFunction.bind(this)}
|
||||
onDataClick={this.makeDataFunction.bind(this)}
|
||||
onZoomClick={this.makeZoomFunction}
|
||||
onDataClick={this.makeDataFunction}
|
||||
onExpressionClick={this.makeExpression}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className={propClass}>
|
||||
return <div className={propClass} data-wd-key={"spec-field:"+this.props.fieldName}>
|
||||
{specField}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -40,26 +40,31 @@ export default class PropertyGroup extends React.Component {
|
||||
groupFields: PropTypes.array.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
spec: PropTypes.object.isRequired,
|
||||
errors: PropTypes.object,
|
||||
}
|
||||
|
||||
onPropertyChange(property, newValue) {
|
||||
onPropertyChange = (property, newValue) => {
|
||||
const group = getGroupName(this.props.spec, this.props.layer.type, property)
|
||||
this.props.onChange(group , property, newValue)
|
||||
}
|
||||
|
||||
render() {
|
||||
const {errors} = this.props;
|
||||
const fields = this.props.groupFields.map(fieldName => {
|
||||
const fieldSpec = getFieldSpec(this.props.spec, this.props.layer.type, fieldName)
|
||||
|
||||
const paint = this.props.layer.paint || {}
|
||||
const layout = this.props.layer.layout || {}
|
||||
const fieldValue = fieldName in paint ? paint[fieldName] : layout[fieldName]
|
||||
const fieldType = fieldName in paint ? 'paint' : 'layout';
|
||||
|
||||
return <FunctionSpecField
|
||||
onChange={this.onPropertyChange.bind(this)}
|
||||
errors={errors}
|
||||
onChange={this.onPropertyChange}
|
||||
key={fieldName}
|
||||
fieldName={fieldName}
|
||||
value={fieldValue === undefined ? fieldSpec.default : fieldValue}
|
||||
value={fieldValue}
|
||||
fieldType={fieldType}
|
||||
fieldSpec={fieldSpec}
|
||||
/>
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import color from 'color'
|
||||
|
||||
import ColorField from './ColorField'
|
||||
import NumberInput from '../inputs/NumberInput'
|
||||
@@ -12,6 +11,7 @@ import ArrayInput from '../inputs/ArrayInput'
|
||||
import DynamicArrayInput from '../inputs/DynamicArrayInput'
|
||||
import FontInput from '../inputs/FontInput'
|
||||
import IconInput from '../inputs/IconInput'
|
||||
import EnumInput from '../inputs/EnumInput'
|
||||
import capitalize from 'lodash.capitalize'
|
||||
|
||||
const iconProperties = ['background-pattern', 'fill-pattern', 'line-pattern', 'fill-extrusion-pattern', 'icon-image']
|
||||
@@ -58,70 +58,75 @@ export default class SpecField extends React.Component {
|
||||
name: this.props.fieldName,
|
||||
onChange: newValue => this.props.onChange(this.props.fieldName, newValue)
|
||||
}
|
||||
switch(this.props.fieldSpec.type) {
|
||||
case 'number': return (
|
||||
<NumberInput
|
||||
{...commonProps}
|
||||
min={this.props.fieldSpec.minimum}
|
||||
max={this.props.fieldSpec.maximum}
|
||||
/>
|
||||
)
|
||||
case 'enum':
|
||||
const options = Object.keys(this.props.fieldSpec.values).map(v => [v, capitalize(v)])
|
||||
|
||||
if(options.length <= 3 && optionsLabelLength(options) <= 20) {
|
||||
return <MultiButtonInput
|
||||
function childNodes() {
|
||||
switch(this.props.fieldSpec.type) {
|
||||
case 'number': return (
|
||||
<NumberInput
|
||||
{...commonProps}
|
||||
min={this.props.fieldSpec.minimum}
|
||||
max={this.props.fieldSpec.maximum}
|
||||
/>
|
||||
)
|
||||
case 'enum':
|
||||
const options = Object.keys(this.props.fieldSpec.values).map(v => [v, capitalize(v)])
|
||||
|
||||
return <EnumInput
|
||||
{...commonProps}
|
||||
options={options}
|
||||
/>
|
||||
} else {
|
||||
return <SelectInput
|
||||
{...commonProps}
|
||||
options={options}
|
||||
/>
|
||||
}
|
||||
case 'string':
|
||||
if(iconProperties.indexOf(this.props.fieldName) >= 0) {
|
||||
return <IconInput
|
||||
{...commonProps}
|
||||
icons={this.props.fieldSpec.values}
|
||||
/>
|
||||
} else {
|
||||
return <StringInput
|
||||
{...commonProps}
|
||||
/>
|
||||
}
|
||||
case 'color': return (
|
||||
<ColorField
|
||||
{...commonProps}
|
||||
/>
|
||||
)
|
||||
case 'boolean': return (
|
||||
<CheckboxInput
|
||||
{...commonProps}
|
||||
/>
|
||||
)
|
||||
case 'array':
|
||||
if(this.props.fieldName === 'text-font') {
|
||||
return <FontInput
|
||||
{...commonProps}
|
||||
fonts={this.props.fieldSpec.values}
|
||||
/>
|
||||
} else {
|
||||
if (this.props.fieldSpec.length) {
|
||||
return <ArrayInput
|
||||
case 'resolvedImage':
|
||||
case 'formatted':
|
||||
case 'string':
|
||||
if(iconProperties.indexOf(this.props.fieldName) >= 0) {
|
||||
return <IconInput
|
||||
{...commonProps}
|
||||
type={this.props.fieldSpec.value}
|
||||
length={this.props.fieldSpec.length}
|
||||
icons={this.props.fieldSpec.values}
|
||||
/>
|
||||
} else {
|
||||
return <DynamicArrayInput
|
||||
return <StringInput
|
||||
{...commonProps}
|
||||
type={this.props.fieldSpec.value}
|
||||
/>
|
||||
}
|
||||
}
|
||||
default: return null
|
||||
case 'color': return (
|
||||
<ColorField
|
||||
{...commonProps}
|
||||
/>
|
||||
)
|
||||
case 'boolean': return (
|
||||
<CheckboxInput
|
||||
{...commonProps}
|
||||
/>
|
||||
)
|
||||
case 'array':
|
||||
if(this.props.fieldName === 'text-font') {
|
||||
return <FontInput
|
||||
{...commonProps}
|
||||
fonts={this.props.fieldSpec.values}
|
||||
/>
|
||||
} else {
|
||||
if (this.props.fieldSpec.length) {
|
||||
return <ArrayInput
|
||||
{...commonProps}
|
||||
type={this.props.fieldSpec.value}
|
||||
length={this.props.fieldSpec.length}
|
||||
/>
|
||||
} else {
|
||||
return <DynamicArrayInput
|
||||
{...commonProps}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
type={this.props.fieldSpec.value}
|
||||
/>
|
||||
}
|
||||
}
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-wd-key={"spec-field:"+this.props.fieldName}>
|
||||
{childNodes.call(this)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,40 @@ import StringInput from '../inputs/StringInput'
|
||||
import SelectInput from '../inputs/SelectInput'
|
||||
import DocLabel from './DocLabel'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import docUid from '../../libs/document-uid'
|
||||
import sortNumerically from '../../libs/sort-numerically'
|
||||
|
||||
import labelFromFieldName from './_labelFromFieldName'
|
||||
import DeleteStopButton from './_DeleteStopButton'
|
||||
|
||||
|
||||
|
||||
function setStopRefs(props, state) {
|
||||
// This is initialsed below only if required to improved performance.
|
||||
let newRefs;
|
||||
|
||||
if(props.value && props.value.stops) {
|
||||
props.value.stops.forEach((val, idx) => {
|
||||
if(!state.refs.hasOwnProperty(idx)) {
|
||||
if(!newRefs) {
|
||||
newRefs = {...state};
|
||||
}
|
||||
newRefs[idx] = docUid("stop-");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return newRefs;
|
||||
}
|
||||
|
||||
export default class DataProperty extends React.Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
onDeleteStop: PropTypes.func,
|
||||
onAddStop: PropTypes.func,
|
||||
onExpressionClick: PropTypes.func,
|
||||
fieldName: PropTypes.string,
|
||||
fieldType: PropTypes.string,
|
||||
fieldSpec: PropTypes.object,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.object,
|
||||
@@ -27,10 +50,35 @@ export default class DataProperty extends React.Component {
|
||||
PropTypes.bool,
|
||||
PropTypes.array
|
||||
]),
|
||||
errors: PropTypes.object,
|
||||
}
|
||||
|
||||
state = {
|
||||
refs: {}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const newRefs = setStopRefs(this.props, this.state);
|
||||
|
||||
if(newRefs) {
|
||||
this.setState({
|
||||
refs: newRefs
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
const newRefs = setStopRefs(props, state);
|
||||
if(newRefs) {
|
||||
return {
|
||||
refs: newRefs
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getFieldFunctionType(fieldSpec) {
|
||||
if (fieldSpec.function === "interpolated") {
|
||||
if (fieldSpec.expression.interpolated) {
|
||||
return "exponential"
|
||||
}
|
||||
if (fieldSpec.type === "number") {
|
||||
@@ -39,8 +87,8 @@ export default class DataProperty extends React.Component {
|
||||
return "categorical"
|
||||
}
|
||||
|
||||
getDataFunctionTypes(functionType) {
|
||||
if (functionType === "interpolated") {
|
||||
getDataFunctionTypes(fieldSpec) {
|
||||
if (fieldSpec.expression.interpolated) {
|
||||
return ["categorical", "interval", "exponential"]
|
||||
}
|
||||
else {
|
||||
@@ -48,13 +96,42 @@ export default class DataProperty extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Order the stops altering the refs to reflect their new position.
|
||||
orderStopsByZoom(stops) {
|
||||
const mappedWithRef = stops
|
||||
.map((stop, idx) => {
|
||||
return {
|
||||
ref: this.state.refs[idx],
|
||||
data: stop
|
||||
}
|
||||
})
|
||||
// Sort by zoom
|
||||
.sort((a, b) => sortNumerically(a.data[0].zoom, b.data[0].zoom));
|
||||
|
||||
// Fetch the new position of the stops
|
||||
const newRefs = {};
|
||||
mappedWithRef
|
||||
.forEach((stop, idx) =>{
|
||||
newRefs[idx] = stop.ref;
|
||||
})
|
||||
|
||||
this.setState({
|
||||
refs: newRefs
|
||||
});
|
||||
|
||||
return mappedWithRef.map((item) => item.data);
|
||||
}
|
||||
|
||||
changeStop(changeIdx, stopData, value) {
|
||||
const stops = this.props.value.stops.slice(0)
|
||||
stops[changeIdx] = [stopData, value]
|
||||
const changedStop = stopData.zoom === undefined ? stopData.value : stopData
|
||||
stops[changeIdx] = [changedStop, value]
|
||||
|
||||
const orderedStops = this.orderStopsByZoom(stops);
|
||||
|
||||
const changedValue = {
|
||||
...this.props.value,
|
||||
stops: stops,
|
||||
stops: orderedStops,
|
||||
}
|
||||
this.props.onChange(this.props.fieldName, changedValue)
|
||||
}
|
||||
@@ -70,13 +147,16 @@ export default class DataProperty extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {fieldName, fieldType, errors} = this.props;
|
||||
|
||||
if (typeof this.props.value.type === "undefined") {
|
||||
this.props.value.type = this.getFieldFunctionType(this.props.fieldSpec)
|
||||
}
|
||||
|
||||
const dataFields = this.props.value.stops.map((stop, idx) => {
|
||||
const zoomLevel = stop[0].zoom
|
||||
const dataLevel = stop[0].value
|
||||
const zoomLevel = typeof stop[0] === 'object' ? stop[0].zoom : undefined;
|
||||
const key = this.state.refs[idx];
|
||||
const dataLevel = typeof stop[0] === 'object' ? stop[0].value : stop[0];
|
||||
const value = stop[1]
|
||||
const deleteStopBtn = <DeleteStopButton onClick={this.props.onDeleteStop.bind(this, idx)} />
|
||||
|
||||
@@ -94,8 +174,9 @@ export default class DataProperty extends React.Component {
|
||||
dataInput = <NumberInput {...dataProps} />
|
||||
}
|
||||
|
||||
return <InputBlock key={idx} action={deleteStopBtn} label="">
|
||||
<div className="maputnik-data-spec-property-stop-edit">
|
||||
let zoomInput = null;
|
||||
if(zoomLevel !== undefined) {
|
||||
zoomInput = <div className="maputnik-data-spec-property-stop-edit">
|
||||
<NumberInput
|
||||
value={zoomLevel}
|
||||
onChange={newZoom => this.changeStop(idx, {zoom: newZoom, value: dataLevel}, value)}
|
||||
@@ -103,6 +184,25 @@ export default class DataProperty extends React.Component {
|
||||
max={22}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
const errorKeyStart = `${fieldType}.${fieldName}.stops[${idx}]`;
|
||||
const foundErrors = Object.entries(errors).filter(([key, error]) => {
|
||||
return key.startsWith(errorKeyStart);
|
||||
});
|
||||
|
||||
const message = foundErrors.map(([key, error]) => {
|
||||
return error.message;
|
||||
}).join("");
|
||||
const error = message ? {message} : undefined;
|
||||
|
||||
return <InputBlock
|
||||
error={error}
|
||||
key={key}
|
||||
action={deleteStopBtn}
|
||||
label=""
|
||||
>
|
||||
{zoomInput}
|
||||
<div className="maputnik-data-spec-property-stop-data">
|
||||
{dataInput}
|
||||
</div>
|
||||
@@ -118,52 +218,51 @@ export default class DataProperty extends React.Component {
|
||||
})
|
||||
|
||||
return <div className="maputnik-data-spec-block">
|
||||
<div className="maputnik-data-spec-property">
|
||||
<InputBlock
|
||||
doc={this.props.fieldSpec.doc}
|
||||
label={labelFromFieldName(this.props.fieldName)}
|
||||
>
|
||||
<div className="maputnik-data-spec-property-group">
|
||||
<DocLabel
|
||||
label="Property"
|
||||
doc={"Input a data property to base styles off of."}
|
||||
/>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<StringInput
|
||||
value={this.props.value.property}
|
||||
onChange={propVal => this.changeDataProperty("property", propVal)}
|
||||
<div className="maputnik-data-spec-property">
|
||||
<InputBlock
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
label={labelFromFieldName(this.props.fieldName)}
|
||||
>
|
||||
<div className="maputnik-data-spec-property-group">
|
||||
<DocLabel
|
||||
label="Property"
|
||||
/>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<StringInput
|
||||
value={this.props.value.property}
|
||||
title={"Input a data property to base styles off of."}
|
||||
onChange={propVal => this.changeDataProperty("property", propVal)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="maputnik-data-spec-property-group">
|
||||
<DocLabel
|
||||
label="Type"
|
||||
doc={"Select a type of data scale (default is 'categorical')."}
|
||||
/>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<SelectInput
|
||||
value={this.props.value.type}
|
||||
onChange={propVal => this.changeDataProperty("type", propVal)}
|
||||
options={this.getDataFunctionTypes(this.props.fieldSpec.function)}
|
||||
<div className="maputnik-data-spec-property-group">
|
||||
<DocLabel
|
||||
label="Type"
|
||||
/>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<SelectInput
|
||||
value={this.props.value.type}
|
||||
onChange={propVal => this.changeDataProperty("type", propVal)}
|
||||
title={"Select a type of data scale (default is 'categorical')."}
|
||||
options={this.getDataFunctionTypes(this.props.fieldSpec)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="maputnik-data-spec-property-group">
|
||||
<DocLabel
|
||||
label="Default"
|
||||
doc={"Input a default value for data if not covered by the scales."}
|
||||
/>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<SpecField
|
||||
fieldName={this.props.fieldName}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
value={this.props.value.default}
|
||||
onChange={(_, propVal) => this.changeDataProperty("default", propVal)}
|
||||
<div className="maputnik-data-spec-property-group">
|
||||
<DocLabel
|
||||
label="Default"
|
||||
/>
|
||||
<div className="maputnik-data-spec-property-input">
|
||||
<SpecField
|
||||
fieldName={this.props.fieldName}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
value={this.props.value.default}
|
||||
onChange={(_, propVal) => this.changeDataProperty("default", propVal)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InputBlock>
|
||||
</div>
|
||||
</InputBlock>
|
||||
</div>
|
||||
{dataFields}
|
||||
<Button
|
||||
className="maputnik-add-stop"
|
||||
@@ -171,6 +270,12 @@ export default class DataProperty extends React.Component {
|
||||
>
|
||||
Add stop
|
||||
</Button>
|
||||
<Button
|
||||
className="maputnik-add-stop"
|
||||
onClick={this.props.onExpressionClick.bind(this)}
|
||||
>
|
||||
Convert to expression
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import DocLabel from './DocLabel'
|
||||
import Button from '../Button'
|
||||
import DeleteIcon from 'react-icons/lib/md/delete'
|
||||
import {MdDelete} from 'react-icons/md'
|
||||
|
||||
|
||||
export default class DeleteStopButton extends React.Component {
|
||||
@@ -15,11 +14,9 @@ export default class DeleteStopButton extends React.Component {
|
||||
return <Button
|
||||
className="maputnik-delete-stop"
|
||||
onClick={this.props.onClick}
|
||||
title={"Remove zoom level stop."}
|
||||
>
|
||||
<DocLabel
|
||||
label={<DeleteIcon />}
|
||||
doc={"Remove zoom level stop."}
|
||||
/>
|
||||
<MdDelete />
|
||||
</Button>
|
||||
}
|
||||
}
|
||||
|
||||
135
src/components/fields/_ExpressionProperty.jsx
Normal file
135
src/components/fields/_ExpressionProperty.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import Button from '../Button'
|
||||
import {MdDelete, MdUndo} from 'react-icons/md'
|
||||
import StringInput from '../inputs/StringInput'
|
||||
|
||||
import labelFromFieldName from './_labelFromFieldName'
|
||||
import stringifyPretty from 'json-stringify-pretty-compact'
|
||||
import JSONEditor from '../layers/JSONEditor'
|
||||
|
||||
|
||||
export default class ExpressionProperty extends React.Component {
|
||||
static propTypes = {
|
||||
onDelete: PropTypes.func,
|
||||
fieldName: PropTypes.string,
|
||||
fieldType: PropTypes.string,
|
||||
fieldSpec: PropTypes.object,
|
||||
value: PropTypes.any,
|
||||
errors: PropTypes.object,
|
||||
onChange: PropTypes.func,
|
||||
onUndo: PropTypes.func,
|
||||
canUndo: PropTypes.func,
|
||||
onFocus: PropTypes.func,
|
||||
onBlur: PropTypes.func,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
errors: {},
|
||||
onFocus: () => {},
|
||||
onBlur: () => {},
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super();
|
||||
this.state = {
|
||||
jsonError: false,
|
||||
};
|
||||
}
|
||||
|
||||
onJSONInvalid = (err) => {
|
||||
this.setState({
|
||||
jsonError: true,
|
||||
})
|
||||
}
|
||||
|
||||
onJSONValid = () => {
|
||||
this.setState({
|
||||
jsonError: false,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const {errors, fieldName, fieldType, value, canUndo} = this.props;
|
||||
const {jsonError} = this.state;
|
||||
const undoDisabled = canUndo ? !canUndo() : true;
|
||||
|
||||
const deleteStopBtn = (
|
||||
<>
|
||||
{this.props.onUndo &&
|
||||
<Button
|
||||
key="undo_action"
|
||||
onClick={this.props.onUndo}
|
||||
disabled={undoDisabled}
|
||||
className="maputnik-delete-stop"
|
||||
>
|
||||
<MdUndo />
|
||||
</Button>
|
||||
}
|
||||
<Button
|
||||
key="delete_action"
|
||||
onClick={this.props.onDelete}
|
||||
className="maputnik-delete-stop"
|
||||
>
|
||||
<MdDelete />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
const fieldKey = fieldType === undefined ? fieldName : `${fieldType}.${fieldName}`;
|
||||
|
||||
const fieldError = errors[fieldKey];
|
||||
const errorKeyStart = `${fieldKey}[`;
|
||||
const foundErrors = [];
|
||||
|
||||
function getValue (data) {
|
||||
return stringifyPretty(data, {indent: 2, maxLength: 38})
|
||||
}
|
||||
|
||||
if (jsonError) {
|
||||
foundErrors.push({message: "Invalid JSON"});
|
||||
}
|
||||
else {
|
||||
Object.entries(errors)
|
||||
.filter(([key, error]) => {
|
||||
return key.startsWith(errorKeyStart);
|
||||
})
|
||||
.forEach(([key, error]) => {
|
||||
return foundErrors.push(error);
|
||||
})
|
||||
|
||||
if (fieldError) {
|
||||
foundErrors.push(fieldError);
|
||||
}
|
||||
}
|
||||
|
||||
return <InputBlock
|
||||
error={foundErrors}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
label={labelFromFieldName(this.props.fieldName)}
|
||||
action={deleteStopBtn}
|
||||
wideMode={true}
|
||||
>
|
||||
<JSONEditor
|
||||
mode={{name: "mgl"}}
|
||||
lint={{
|
||||
context: "expression",
|
||||
spec: this.props.fieldSpec,
|
||||
}}
|
||||
className="maputnik-expression-editor"
|
||||
onFocus={this.props.onFocus}
|
||||
onBlur={this.props.onBlur}
|
||||
onJSONInvalid={this.onJSONInvalid}
|
||||
onJSONValid={this.onJSONValid}
|
||||
layer={value}
|
||||
lineNumbers={false}
|
||||
maxHeight={200}
|
||||
lineWrapping={true}
|
||||
getValue={getValue}
|
||||
onChange={this.props.onChange}
|
||||
/>
|
||||
</InputBlock>
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,77 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import DocLabel from './DocLabel'
|
||||
import Button from '../Button'
|
||||
import FunctionIcon from 'react-icons/lib/md/functions'
|
||||
import MdInsertChart from 'react-icons/lib/md/insert-chart'
|
||||
import {MdFunctions, MdInsertChart} from 'react-icons/md'
|
||||
import {mdiFunctionVariant} from '@mdi/js';
|
||||
|
||||
|
||||
/**
|
||||
* So here we can't just check is `Array.isArray(value)` because certain
|
||||
* properties accept arrays as values, for example `text-font`. So we must try
|
||||
* and create an expression.
|
||||
*/
|
||||
function isExpression(value, fieldSpec={}) {
|
||||
if (!Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
expression.createExpression(value, fieldSpec);
|
||||
return true;
|
||||
}
|
||||
catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default class FunctionButtons extends React.Component {
|
||||
static propTypes = {
|
||||
fieldSpec: PropTypes.object,
|
||||
onZoomClick: PropTypes.func,
|
||||
onDataClick: PropTypes.func,
|
||||
onExpressionClick: PropTypes.func,
|
||||
}
|
||||
|
||||
render() {
|
||||
let makeZoomButton, makeDataButton
|
||||
if (this.props.fieldSpec['zoom-function']) {
|
||||
let makeZoomButton, makeDataButton, expressionButton;
|
||||
|
||||
if (this.props.fieldSpec.expression.parameters.includes('zoom')) {
|
||||
expressionButton = (
|
||||
<Button
|
||||
className="maputnik-make-zoom-function"
|
||||
onClick={this.props.onExpressionClick}
|
||||
>
|
||||
<svg style={{width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d={mdiFunctionVariant} />
|
||||
</svg>
|
||||
</Button>
|
||||
);
|
||||
|
||||
makeZoomButton = <Button
|
||||
className="maputnik-make-zoom-function"
|
||||
onClick={this.props.onZoomClick}
|
||||
title={"Turn property into a zoom function to enable a map feature to change with map's zoom level."}
|
||||
>
|
||||
<DocLabel
|
||||
label={<FunctionIcon />}
|
||||
cursorTargetStyle={{ cursor: 'pointer' }}
|
||||
doc={"Turn property into a zoom function to enable a map feature to change with map's zoom level."}
|
||||
/>
|
||||
<MdFunctions />
|
||||
</Button>
|
||||
|
||||
if (this.props.fieldSpec['property-function'] && ['piecewise-constant', 'interpolated'].indexOf(this.props.fieldSpec['function']) !== -1) {
|
||||
if (this.props.fieldSpec['property-type'] === 'data-driven') {
|
||||
makeDataButton = <Button
|
||||
className="maputnik-make-data-function"
|
||||
onClick={this.props.onDataClick}
|
||||
title={"Turn property into a data function to enable a map feature to change according to data properties and the map's zoom level."}
|
||||
>
|
||||
<DocLabel
|
||||
label={<MdInsertChart />}
|
||||
cursorTargetStyle={{ cursor: 'pointer' }}
|
||||
doc={"Turn property into a data function to enable a map feature to change according to data properties and the map's zoom level."}
|
||||
/>
|
||||
<MdInsertChart />
|
||||
</Button>
|
||||
}
|
||||
return <div>{makeDataButton}{makeZoomButton}</div>
|
||||
return <div>
|
||||
{expressionButton}
|
||||
{makeDataButton}
|
||||
{makeZoomButton}
|
||||
</div>
|
||||
}
|
||||
else {
|
||||
return null
|
||||
return <div>{expressionButton}</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,18 +13,33 @@ export default class SpecProperty extends React.Component {
|
||||
onZoomClick: PropTypes.func.isRequired,
|
||||
onDataClick: PropTypes.func.isRequired,
|
||||
fieldName: PropTypes.string,
|
||||
fieldSpec: PropTypes.object
|
||||
fieldType: PropTypes.string,
|
||||
fieldSpec: PropTypes.object,
|
||||
value: PropTypes.any,
|
||||
errors: PropTypes.object,
|
||||
onExpressionClick: PropTypes.func,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
errors: {},
|
||||
}
|
||||
|
||||
render() {
|
||||
const {errors, fieldName, fieldType} = this.props;
|
||||
|
||||
const functionBtn = <FunctionButtons
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
onZoomClick={this.props.onZoomClick}
|
||||
onDataClick={this.props.onDataClick}
|
||||
value={this.props.value}
|
||||
onExpressionClick={this.props.onExpressionClick}
|
||||
/>
|
||||
|
||||
const error = errors[fieldType+"."+fieldName];
|
||||
|
||||
return <InputBlock
|
||||
doc={this.props.fieldSpec.doc}
|
||||
error={error}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
label={labelFromFieldName(this.props.fieldName)}
|
||||
action={functionBtn}
|
||||
>
|
||||
|
||||
@@ -13,13 +13,40 @@ import docUid from '../../libs/document-uid'
|
||||
import sortNumerically from '../../libs/sort-numerically'
|
||||
|
||||
|
||||
/**
|
||||
* We cache a reference for each stop by its index.
|
||||
*
|
||||
* When the stops are reordered the references are also updated (see this.orderStops) this allows React to use the same key for the element and keep keyboard focus.
|
||||
*/
|
||||
function setStopRefs(props, state) {
|
||||
// This is initialsed below only if required to improved performance.
|
||||
let newRefs;
|
||||
|
||||
if(props.value && props.value.stops) {
|
||||
props.value.stops.forEach((val, idx) => {
|
||||
if(!state.refs.hasOwnProperty(idx)) {
|
||||
if(!newRefs) {
|
||||
newRefs = {...state};
|
||||
}
|
||||
newRefs[idx] = docUid("stop-");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return newRefs;
|
||||
}
|
||||
|
||||
|
||||
export default class ZoomProperty extends React.Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
onDeleteStop: PropTypes.func,
|
||||
onAddStop: PropTypes.func,
|
||||
onExpressionClick: PropTypes.func,
|
||||
fieldType: PropTypes.string,
|
||||
fieldName: PropTypes.string,
|
||||
fieldSpec: PropTypes.object,
|
||||
errors: PropTypes.object,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.object,
|
||||
PropTypes.string,
|
||||
@@ -29,45 +56,17 @@ export default class ZoomProperty extends React.Component {
|
||||
]),
|
||||
}
|
||||
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.state = {
|
||||
refs: {}
|
||||
}
|
||||
static defaultProps = {
|
||||
errors: {},
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.setState({
|
||||
refs: this.setStopRefs(this.props)
|
||||
})
|
||||
state = {
|
||||
refs: {}
|
||||
}
|
||||
|
||||
/**
|
||||
* We cache a reference for each stop by its index.
|
||||
*
|
||||
* When the stops are reordered the references are also updated (see this.orderStops) this allows React to use the same key for the element and keep keyboard focus.
|
||||
*/
|
||||
setStopRefs(props) {
|
||||
// This is initialsed below only if required to improved performance.
|
||||
let newRefs;
|
||||
componentDidMount() {
|
||||
const newRefs = setStopRefs(this.props, this.state);
|
||||
|
||||
if(props.value && props.value.stops) {
|
||||
props.value.stops.forEach((val, idx) => {
|
||||
if(!this.state.refs.hasOwnProperty(idx)) {
|
||||
if(!newRefs) {
|
||||
newRefs = {...this.state.refs};
|
||||
}
|
||||
newRefs[idx] = docUid("stop-");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return newRefs;
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const newRefs = this.setStopRefs(nextProps);
|
||||
if(newRefs) {
|
||||
this.setState({
|
||||
refs: newRefs
|
||||
@@ -75,6 +74,16 @@ export default class ZoomProperty extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
const newRefs = setStopRefs(props, state);
|
||||
if(newRefs) {
|
||||
return {
|
||||
refs: newRefs
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Order the stops altering the refs to reflect their new position.
|
||||
orderStopsByZoom(stops) {
|
||||
const mappedWithRef = stops
|
||||
@@ -115,15 +124,28 @@ export default class ZoomProperty extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {fieldName, fieldType, errors} = this.props;
|
||||
|
||||
const zoomFields = this.props.value.stops.map((stop, idx) => {
|
||||
const zoomLevel = stop[0]
|
||||
const key = this.state.refs[idx];
|
||||
const value = stop[1]
|
||||
const deleteStopBtn= <DeleteStopButton onClick={this.props.onDeleteStop.bind(this, idx)} />
|
||||
|
||||
const errorKeyStart = `${fieldType}.${fieldName}.stops[${idx}]`;
|
||||
const foundErrors = Object.entries(errors).filter(([key, error]) => {
|
||||
return key.startsWith(errorKeyStart);
|
||||
});
|
||||
|
||||
const message = foundErrors.map(([key, error]) => {
|
||||
return error.message;
|
||||
}).join("");
|
||||
const error = message ? {message} : undefined;
|
||||
|
||||
return <InputBlock
|
||||
error={error}
|
||||
key={key}
|
||||
doc={this.props.fieldSpec.doc}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
label={labelFromFieldName(this.props.fieldName)}
|
||||
action={deleteStopBtn}
|
||||
>
|
||||
@@ -156,6 +178,12 @@ export default class ZoomProperty extends React.Component {
|
||||
>
|
||||
Add stop
|
||||
</Button>
|
||||
<Button
|
||||
className="maputnik-add-stop"
|
||||
onClick={this.props.onExpressionClick.bind(this)}
|
||||
>
|
||||
Convert to expression
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import capitalize from 'lodash.capitalize'
|
||||
|
||||
export default function labelFromFieldName(fieldName) {
|
||||
let label = fieldName.split('-').slice(1).join(' ')
|
||||
return capitalize(label)
|
||||
let label;
|
||||
const parts = fieldName.split('-');
|
||||
if (parts.length > 1) {
|
||||
label = fieldName.split('-').slice(1).join(' ');
|
||||
}
|
||||
else {
|
||||
label = fieldName;
|
||||
}
|
||||
return capitalize(label);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,84 @@ import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { combiningFilterOps } from '../../libs/filterops.js'
|
||||
|
||||
import styleSpec from '@mapbox/mapbox-gl-style-spec/style-spec'
|
||||
import {latest, validate, migrate} from '@mapbox/mapbox-gl-style-spec'
|
||||
import DocLabel from '../fields/DocLabel'
|
||||
import SelectInput from '../inputs/SelectInput'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import SingleFilterEditor from './SingleFilterEditor'
|
||||
import FilterEditorBlock from './FilterEditorBlock'
|
||||
import Button from '../Button'
|
||||
import SpecDoc from '../inputs/SpecDoc'
|
||||
import ExpressionProperty from '../fields/_ExpressionProperty';
|
||||
import {mdiFunctionVariant} from '@mdi/js';
|
||||
|
||||
import DeleteIcon from 'react-icons/lib/md/delete'
|
||||
import AddIcon from 'react-icons/lib/fa/plus'
|
||||
|
||||
function combiningFilter (props) {
|
||||
let filter = props.filter || ['all'];
|
||||
|
||||
if (!Array.isArray(filter)) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
let combiningOp = filter[0];
|
||||
let filters = filter.slice(1);
|
||||
|
||||
if(combiningFilterOps.indexOf(combiningOp) < 0) {
|
||||
combiningOp = 'all';
|
||||
filters = [filter.slice(0)];
|
||||
}
|
||||
|
||||
return [combiningOp, ...filters];
|
||||
}
|
||||
|
||||
function migrateFilter (filter) {
|
||||
return migrate(createStyleFromFilter(filter)).layers[0].filter;
|
||||
}
|
||||
|
||||
function createStyleFromFilter (filter) {
|
||||
return {
|
||||
"id": "tmp",
|
||||
"version": 8,
|
||||
"name": "Empty Style",
|
||||
"metadata": {"maputnik:renderer": "mbgljs"},
|
||||
"sources": {
|
||||
"tmp": {
|
||||
"type": "geojson",
|
||||
"data": {}
|
||||
}
|
||||
},
|
||||
"sprite": "",
|
||||
"glyphs": "https://orangemug.github.io/font-glyphs/glyphs/{fontstack}/{range}.pbf",
|
||||
"layers": [
|
||||
{
|
||||
id: "tmp",
|
||||
type: "fill",
|
||||
source: "tmp",
|
||||
filter: filter,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This is doing way more work than we need it to, however validating a whole
|
||||
* style if the only thing that's exported from mapbox-gl-style-spec at the
|
||||
* moment. Not really an issue though as it take ~0.1ms to calculate.
|
||||
*/
|
||||
function checkIfSimpleFilter (filter) {
|
||||
if (!filter || !combiningFilterOps.includes(filter[0])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Because "none" isn't supported by the next expression syntax we can test
|
||||
// with ["none", ...] because it'll return false if it's a new style
|
||||
// expression.
|
||||
const moddedFilter = ["none", ...filter.slice(1)];
|
||||
const tmpStyle = createStyleFromFilter(moddedFilter)
|
||||
|
||||
const errors = validate(tmpStyle);
|
||||
return (errors.length < 1);
|
||||
}
|
||||
|
||||
function hasCombiningFilter(filter) {
|
||||
return combiningFilterOps.indexOf(filter[0]) >= 0
|
||||
@@ -29,85 +98,212 @@ export default class CombiningFilterEditor extends React.Component {
|
||||
/** Properties of the vector layer and the available fields */
|
||||
properties: PropTypes.object,
|
||||
filter: PropTypes.array,
|
||||
errors: PropTypes.object,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
// Convert filter to combining filter
|
||||
combiningFilter() {
|
||||
let filter = this.props.filter || ['all']
|
||||
|
||||
let combiningOp = filter[0]
|
||||
let filters = filter.slice(1)
|
||||
|
||||
if(combiningFilterOps.indexOf(combiningOp) < 0) {
|
||||
combiningOp = 'all'
|
||||
filters = [filter.slice(0)]
|
||||
}
|
||||
|
||||
return [combiningOp, ...filters]
|
||||
static defaultProps = {
|
||||
filter: ["all"],
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super();
|
||||
this.state = {
|
||||
showDoc: false,
|
||||
displaySimpleFilter: checkIfSimpleFilter(combiningFilter(props)),
|
||||
};
|
||||
}
|
||||
|
||||
// Convert filter to combining filter
|
||||
onFilterPartChanged(filterIdx, newPart) {
|
||||
const newFilter = this.combiningFilter().slice(0)
|
||||
const newFilter = combiningFilter(this.props).slice(0)
|
||||
newFilter[filterIdx] = newPart
|
||||
this.props.onChange(newFilter)
|
||||
}
|
||||
|
||||
deleteFilterItem(filterIdx) {
|
||||
const newFilter = this.combiningFilter().slice(0)
|
||||
console.log('Delete', filterIdx, newFilter)
|
||||
const newFilter = combiningFilter(this.props).slice(0)
|
||||
newFilter.splice(filterIdx + 1, 1)
|
||||
this.props.onChange(newFilter)
|
||||
}
|
||||
|
||||
addFilterItem() {
|
||||
const newFilterItem = this.combiningFilter().slice(0)
|
||||
addFilterItem = () => {
|
||||
const newFilterItem = combiningFilter(this.props).slice(0)
|
||||
newFilterItem.push(['==', 'name', ''])
|
||||
this.props.onChange(newFilterItem)
|
||||
}
|
||||
|
||||
render() {
|
||||
const filter = this.combiningFilter()
|
||||
let combiningOp = filter[0]
|
||||
let filters = filter.slice(1)
|
||||
onToggleDoc = (val) => {
|
||||
this.setState({
|
||||
showDoc: val
|
||||
});
|
||||
}
|
||||
|
||||
const editorBlocks = filters.map((f, idx) => {
|
||||
return <FilterEditorBlock key={idx} onDelete={this.deleteFilterItem.bind(this, idx)}>
|
||||
<SingleFilterEditor
|
||||
properties={this.props.properties}
|
||||
filter={f}
|
||||
onChange={this.onFilterPartChanged.bind(this, idx + 1)}
|
||||
/>
|
||||
</FilterEditorBlock>
|
||||
makeFilter = () => {
|
||||
this.setState({
|
||||
displaySimpleFilter: true,
|
||||
})
|
||||
}
|
||||
|
||||
//TODO: Implement support for nested filter
|
||||
if(hasNestedCombiningFilter(filter)) {
|
||||
return <div className="maputnik-filter-editor-unsupported">
|
||||
Nested filters are not supported.
|
||||
</div>
|
||||
makeExpression = () => {
|
||||
let filter = combiningFilter(this.props);
|
||||
this.props.onChange(migrateFilter(filter));
|
||||
this.setState({
|
||||
displaySimpleFilter: false,
|
||||
})
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps (props, currentState) {
|
||||
const {filter} = props;
|
||||
const displaySimpleFilter = checkIfSimpleFilter(combiningFilter(props));
|
||||
|
||||
// Upgrade but never downgrade
|
||||
if (!displaySimpleFilter && currentState.displaySimpleFilter === true) {
|
||||
return {
|
||||
displaySimpleFilter: false,
|
||||
valueIsSimpleFilter: false,
|
||||
};
|
||||
}
|
||||
else if (displaySimpleFilter && currentState.displaySimpleFilter === false) {
|
||||
return {
|
||||
valueIsSimpleFilter: true,
|
||||
}
|
||||
}
|
||||
else {
|
||||
return {
|
||||
valueIsSimpleFilter: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="maputnik-filter-editor">
|
||||
<div className="maputnik-filter-editor-compound-select">
|
||||
<DocLabel
|
||||
label={"Compound Filter"}
|
||||
doc={styleSpec.latest.layer.filter.doc + " Combine multiple filters together by using a compound filter."}
|
||||
/>
|
||||
<SelectInput
|
||||
value={combiningOp}
|
||||
onChange={this.onFilterPartChanged.bind(this, 0)}
|
||||
options={[["all", "every filter matches"], ["none", "no filter matches"], ["any", "any filter matches"]]}
|
||||
/>
|
||||
</div>
|
||||
{editorBlocks}
|
||||
<div className="maputnik-filter-editor-add-wrapper">
|
||||
render() {
|
||||
const {errors} = this.props;
|
||||
const {displaySimpleFilter} = this.state;
|
||||
const fieldSpec={
|
||||
doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter."
|
||||
};
|
||||
const defaultFilter = ["all"];
|
||||
|
||||
const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(combiningFilter(this.props));
|
||||
|
||||
if (isNestedCombiningFilter) {
|
||||
return <div className="maputnik-filter-editor-unsupported">
|
||||
<p>
|
||||
Nested filters are not supported.
|
||||
</p>
|
||||
<Button
|
||||
className="maputnik-add-filter"
|
||||
onClick={this.addFilterItem.bind(this)}>
|
||||
Add filter
|
||||
onClick={this.makeExpression}
|
||||
>
|
||||
<svg style={{marginRight: "0.2em", width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d={mdiFunctionVariant} />
|
||||
</svg>
|
||||
Upgrade to expression
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (displaySimpleFilter) {
|
||||
const filter = combiningFilter(this.props);
|
||||
let combiningOp = filter[0];
|
||||
let filters = filter.slice(1)
|
||||
|
||||
const actions = (
|
||||
<div>
|
||||
<Button
|
||||
onClick={this.makeExpression}
|
||||
className="maputnik-make-zoom-function"
|
||||
>
|
||||
<svg style={{width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d={mdiFunctionVariant} />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const editorBlocks = filters.map((f, idx) => {
|
||||
const error = errors[`filter[${idx+1}]`];
|
||||
|
||||
return (
|
||||
<div key={`block-${idx}`}>
|
||||
<FilterEditorBlock key={idx} onDelete={this.deleteFilterItem.bind(this, idx)}>
|
||||
<SingleFilterEditor
|
||||
properties={this.props.properties}
|
||||
filter={f}
|
||||
onChange={this.onFilterPartChanged.bind(this, idx + 1)}
|
||||
/>
|
||||
</FilterEditorBlock>
|
||||
{error &&
|
||||
<div key="error" className="maputnik-inline-error">{error.message}</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputBlock
|
||||
key="top"
|
||||
fieldSpec={fieldSpec}
|
||||
label={"Filter"}
|
||||
action={actions}
|
||||
>
|
||||
<SelectInput
|
||||
value={combiningOp}
|
||||
onChange={this.onFilterPartChanged.bind(this, 0)}
|
||||
options={[["all", "every filter matches"], ["none", "no filter matches"], ["any", "any filter matches"]]}
|
||||
/>
|
||||
</InputBlock>
|
||||
{editorBlocks}
|
||||
<div
|
||||
key="buttons"
|
||||
className="maputnik-filter-editor-add-wrapper"
|
||||
>
|
||||
<Button
|
||||
data-wd-key="layer-filter-button"
|
||||
className="maputnik-add-filter"
|
||||
onClick={this.addFilterItem}>
|
||||
Add filter
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
key="doc"
|
||||
className="maputnik-doc-inline"
|
||||
style={{display: this.state.showDoc ? '' : 'none'}}
|
||||
>
|
||||
<SpecDoc fieldSpec={fieldSpec} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
else {
|
||||
let {filter} = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExpressionProperty
|
||||
onDelete={() => {
|
||||
this.setState({displaySimpleFilter: true});
|
||||
this.props.onChange(defaultFilter);
|
||||
}}
|
||||
fieldName="filter"
|
||||
fieldSpec={fieldSpec}
|
||||
value={filter}
|
||||
errors={errors}
|
||||
onChange={this.props.onChange}
|
||||
/>
|
||||
{this.state.valueIsSimpleFilter &&
|
||||
<div className="maputnik-expr-infobox">
|
||||
You've entered a old style filter,{' '}
|
||||
<button
|
||||
onClick={this.makeFilter}
|
||||
className="maputnik-expr-infobox__button"
|
||||
>
|
||||
switch to filter editor
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Button from '../Button'
|
||||
import DeleteIcon from 'react-icons/lib/md/delete'
|
||||
import {MdDelete} from 'react-icons/md'
|
||||
|
||||
class FilterEditorBlock extends React.Component {
|
||||
static propTypes = {
|
||||
@@ -16,7 +16,7 @@ class FilterEditorBlock extends React.Component {
|
||||
className="maputnik-delete-filter"
|
||||
onClick={this.props.onDelete}
|
||||
>
|
||||
<DeleteIcon />
|
||||
<MdDelete />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="maputnik-filter-editor-block-content">
|
||||
|
||||
@@ -6,6 +6,7 @@ import FillIcon from './FillIcon.jsx'
|
||||
import SymbolIcon from './SymbolIcon.jsx'
|
||||
import BackgroundIcon from './BackgroundIcon.jsx'
|
||||
import CircleIcon from './CircleIcon.jsx'
|
||||
import MissingIcon from './MissingIcon.jsx'
|
||||
|
||||
class LayerIcon extends React.Component {
|
||||
static propTypes = {
|
||||
@@ -18,12 +19,14 @@ class LayerIcon extends React.Component {
|
||||
switch(this.props.type) {
|
||||
case 'fill-extrusion': return <BackgroundIcon {...iconProps} />
|
||||
case 'raster': return <FillIcon {...iconProps} />
|
||||
case 'hillshade': return <FillIcon {...iconProps} />
|
||||
case 'heatmap': return <FillIcon {...iconProps} />
|
||||
case 'fill': return <FillIcon {...iconProps} />
|
||||
case 'background': return <BackgroundIcon {...iconProps} />
|
||||
case 'line': return <LineIcon {...iconProps} />
|
||||
case 'symbol': return <SymbolIcon {...iconProps} />
|
||||
case 'circle': return <CircleIcon {...iconProps} />
|
||||
default: return null
|
||||
default: return <MissingIcon {...iconProps} />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
src/components/icons/MissingIcon.jsx
Normal file
11
src/components/icons/MissingIcon.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import {MdPriorityHigh} from 'react-icons/md'
|
||||
|
||||
|
||||
export default class MissingIcon extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<MdPriorityHigh {...this.props} />
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,29 +12,89 @@ class ArrayInput extends React.Component {
|
||||
onChange: PropTypes.func,
|
||||
}
|
||||
|
||||
changeValue(idx, newValue) {
|
||||
console.log(idx, newValue)
|
||||
const values = this.values.slice(0)
|
||||
values[idx] = newValue
|
||||
this.props.onChange(values)
|
||||
static defaultProps = {
|
||||
value: [],
|
||||
default: [],
|
||||
}
|
||||
|
||||
get values() {
|
||||
return this.props.value || this.props.default || []
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: this.props.value.slice(0),
|
||||
// This is so we can compare changes in getDerivedStateFromProps
|
||||
initialPropsValue: this.props.value.slice(0),
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
const value = [];
|
||||
const initialPropsValue = state.initialPropsValue.slice(0);
|
||||
|
||||
Array(props.length).fill(null).map((_, i) => {
|
||||
if (props.value[i] === state.initialPropsValue[i]) {
|
||||
value[i] = state.value[i];
|
||||
}
|
||||
else {
|
||||
value[i] = state.value[i];
|
||||
initialPropsValue[i] = state.value[i];
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
value,
|
||||
initialPropsValue,
|
||||
};
|
||||
}
|
||||
|
||||
isComplete (value) {
|
||||
return Array(this.props.length).fill(null).every((_, i) => {
|
||||
const val = value[i]
|
||||
return !(val === undefined || val === "");
|
||||
});
|
||||
}
|
||||
|
||||
changeValue(idx, newValue) {
|
||||
const value = this.state.value.slice(0);
|
||||
value[idx] = newValue;
|
||||
|
||||
this.setState({
|
||||
value,
|
||||
}, () => {
|
||||
if (this.isComplete(value)) {
|
||||
this.props.onChange(value);
|
||||
}
|
||||
else {
|
||||
// Unset until complete
|
||||
this.props.onChange(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const inputs = this.values.map((v, i) => {
|
||||
const {value} = this.state;
|
||||
|
||||
const containsValues = (
|
||||
value.length > 0 &&
|
||||
!value.every(val => {
|
||||
return (val === "" || val === undefined)
|
||||
})
|
||||
);
|
||||
|
||||
const inputs = Array(this.props.length).fill(null).map((_, i) => {
|
||||
if(this.props.type === 'number') {
|
||||
return <NumberInput
|
||||
key={i}
|
||||
value={v}
|
||||
default={containsValues ? undefined : this.props.default[i]}
|
||||
value={value[i]}
|
||||
required={containsValues ? true : false}
|
||||
onChange={this.changeValue.bind(this, i)}
|
||||
/>
|
||||
} else {
|
||||
return <StringInput
|
||||
key={i}
|
||||
value={v}
|
||||
default={containsValues ? undefined : this.props.default[i]}
|
||||
value={value[i]}
|
||||
required={containsValues ? true : false}
|
||||
onChange={this.changeValue.bind(this, i)}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -14,18 +14,15 @@ class AutocompleteInput extends React.Component {
|
||||
keepMenuWithinWindowBounds: PropTypes.bool
|
||||
}
|
||||
|
||||
state = {
|
||||
maxHeight: MAX_HEIGHT
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onChange: () => {},
|
||||
options: [],
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
maxHeight: MAX_HEIGHT
|
||||
};
|
||||
}
|
||||
|
||||
calcMaxHeight() {
|
||||
if(this.props.keepMenuWithinWindowBounds) {
|
||||
const maxHeight = window.innerHeight - this.autocompleteMenuEl.getBoundingClientRect().top;
|
||||
@@ -38,6 +35,7 @@ class AutocompleteInput extends React.Component {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.calcMaxHeight();
|
||||
}
|
||||
@@ -46,6 +44,10 @@ class AutocompleteInput extends React.Component {
|
||||
this.calcMaxHeight();
|
||||
}
|
||||
|
||||
onChange (v) {
|
||||
this.props.onChange(v === "" ? undefined : v);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div
|
||||
ref={(el) => {
|
||||
@@ -54,24 +56,28 @@ class AutocompleteInput extends React.Component {
|
||||
>
|
||||
<Autocomplete
|
||||
menuStyle={{
|
||||
position: "absolute",
|
||||
position: "fixed",
|
||||
overflow: "auto",
|
||||
maxHeight: this.state.maxHeight
|
||||
maxHeight: this.state.maxHeight,
|
||||
zIndex: '998'
|
||||
}}
|
||||
wrapperProps={{
|
||||
className: "maputnik-autocomplete",
|
||||
style: null
|
||||
}}
|
||||
inputProps={{
|
||||
className: "maputnik-string"
|
||||
className: "maputnik-string",
|
||||
spellCheck: false
|
||||
}}
|
||||
value={this.props.value}
|
||||
items={this.props.options}
|
||||
getItemValue={(item) => item[0]}
|
||||
onSelect={v => this.props.onChange(v)}
|
||||
onChange={(e, v) => this.props.onChange(v)}
|
||||
shouldItemRender={(item, value) => {
|
||||
return item[0].toLowerCase().indexOf(value.toLowerCase()) > -1
|
||||
onSelect={v => this.onChange(v)}
|
||||
onChange={(e, v) => this.onChange(v)}
|
||||
shouldItemRender={(item, value="") => {
|
||||
if (typeof(value) === "string") {
|
||||
return item[0].toLowerCase().indexOf(value.toLowerCase()) > -1
|
||||
}
|
||||
}}
|
||||
renderItem={(item, isHighlighted) => (
|
||||
<div
|
||||
|
||||
@@ -3,11 +3,15 @@ import PropTypes from 'prop-types'
|
||||
|
||||
class CheckboxInput extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.bool.isRequired,
|
||||
value: PropTypes.bool,
|
||||
style: PropTypes.object,
|
||||
onChange: PropTypes.func,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
value: false,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <label className="maputnik-checkbox-wrapper">
|
||||
<input
|
||||
|
||||
@@ -3,8 +3,10 @@ import PropTypes from 'prop-types'
|
||||
import StringInput from './StringInput'
|
||||
import NumberInput from './NumberInput'
|
||||
import Button from '../Button'
|
||||
import DeleteIcon from 'react-icons/lib/md/delete'
|
||||
import {MdDelete} from 'react-icons/md'
|
||||
import DocLabel from '../fields/DocLabel'
|
||||
import EnumInput from '../inputs/SelectInput'
|
||||
import capitalize from 'lodash.capitalize'
|
||||
|
||||
|
||||
class DynamicArrayInput extends React.Component {
|
||||
@@ -14,6 +16,7 @@ class DynamicArrayInput extends React.Component {
|
||||
default: PropTypes.array,
|
||||
onChange: PropTypes.func,
|
||||
style: PropTypes.object,
|
||||
fieldSpec: PropTypes.object,
|
||||
}
|
||||
|
||||
changeValue(idx, newValue) {
|
||||
@@ -27,14 +30,18 @@ class DynamicArrayInput extends React.Component {
|
||||
return this.props.value || this.props.default || []
|
||||
}
|
||||
|
||||
addValue() {
|
||||
addValue = () => {
|
||||
const values = this.values.slice(0)
|
||||
if (this.props.type === 'number') {
|
||||
values.push(0)
|
||||
}
|
||||
else if (this.props.type === 'enum') {
|
||||
const {fieldSpec} = this.props;
|
||||
const defaultValue = Object.keys(fieldSpec.values)[0];
|
||||
values.push(defaultValue);
|
||||
} else {
|
||||
values.push("")
|
||||
}
|
||||
|
||||
|
||||
this.props.onChange(values)
|
||||
}
|
||||
@@ -49,15 +56,28 @@ class DynamicArrayInput extends React.Component {
|
||||
render() {
|
||||
const inputs = this.values.map((v, i) => {
|
||||
const deleteValueBtn= <DeleteValueButton onClick={this.deleteValue.bind(this, i)} />
|
||||
const input = this.props.type === 'number'
|
||||
? <NumberInput
|
||||
let input;
|
||||
if (this.props.type === 'number') {
|
||||
input = <NumberInput
|
||||
value={v}
|
||||
onChange={this.changeValue.bind(this, i)}
|
||||
/>
|
||||
: <StringInput
|
||||
}
|
||||
else if (this.props.type === 'enum') {
|
||||
const options = Object.keys(this.props.fieldSpec.values).map(v => [v, capitalize(v)]);
|
||||
|
||||
input = <EnumInput
|
||||
options={options}
|
||||
value={v}
|
||||
onChange={this.changeValue.bind(this, i)}
|
||||
/>
|
||||
}
|
||||
else {
|
||||
input = <StringInput
|
||||
value={v}
|
||||
onChange={this.changeValue.bind(this, i)}
|
||||
/>
|
||||
}
|
||||
|
||||
return <div
|
||||
style={this.props.style}
|
||||
@@ -77,7 +97,7 @@ class DynamicArrayInput extends React.Component {
|
||||
{inputs}
|
||||
<Button
|
||||
className="maputnik-array-add-value"
|
||||
onClick={this.addValue.bind(this)}
|
||||
onClick={this.addValue}
|
||||
>
|
||||
Add value
|
||||
</Button>
|
||||
@@ -96,7 +116,7 @@ class DeleteValueButton extends React.Component {
|
||||
onClick={this.props.onClick}
|
||||
>
|
||||
<DocLabel
|
||||
label={<DeleteIcon />}
|
||||
label={<MdDelete />}
|
||||
doc={"Remove array entry."}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
45
src/components/inputs/EnumInput.jsx
Normal file
45
src/components/inputs/EnumInput.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import SelectInput from '../inputs/SelectInput'
|
||||
import MultiButtonInput from '../inputs/MultiButtonInput'
|
||||
|
||||
|
||||
function optionsLabelLength(options) {
|
||||
let sum = 0;
|
||||
options.forEach(([_, label]) => {
|
||||
sum += label.length
|
||||
})
|
||||
return sum
|
||||
}
|
||||
|
||||
|
||||
class EnumInput extends React.Component {
|
||||
static propTypes = {
|
||||
"data-wd-key": PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
default: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
options: PropTypes.array,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {options, value, onChange} = this.props;
|
||||
|
||||
if(options.length <= 3 && optionsLabelLength(options) <= 20) {
|
||||
return <MultiButtonInput
|
||||
options={options}
|
||||
value={value || this.props.default}
|
||||
onChange={onChange}
|
||||
/>
|
||||
} else {
|
||||
return <SelectInput
|
||||
options={options}
|
||||
value={value || this.props.default}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default EnumInput
|
||||
@@ -16,13 +16,25 @@ class FontInput extends React.Component {
|
||||
}
|
||||
|
||||
get values() {
|
||||
return this.props.value || this.props.default.slice(1) || []
|
||||
const out = this.props.value || this.props.default.slice(1) || [""];
|
||||
|
||||
// Always put a "" in the last field to you can keep adding entries
|
||||
if (out[out.length-1] !== ""){
|
||||
return out.concat("");
|
||||
}
|
||||
else {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
changeFont(idx, newValue) {
|
||||
const changedValues = this.values.slice(0)
|
||||
changedValues[idx] = newValue
|
||||
this.props.onChange(changedValues)
|
||||
const filteredValues = changedValues
|
||||
.filter(v => v !== undefined)
|
||||
.filter(v => v !== "")
|
||||
|
||||
this.props.onChange(filteredValues);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -5,7 +5,7 @@ import AutocompleteInput from './AutocompleteInput'
|
||||
|
||||
class IconInput extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.array,
|
||||
value: PropTypes.string,
|
||||
icons: PropTypes.array,
|
||||
style: PropTypes.object,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
|
||||
@@ -2,42 +2,65 @@ import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import classnames from 'classnames'
|
||||
import DocLabel from '../fields/DocLabel'
|
||||
import SpecDoc from './SpecDoc'
|
||||
|
||||
|
||||
/** Wrap a component with a label */
|
||||
class InputBlock extends React.Component {
|
||||
static propTypes = {
|
||||
"data-wd-key": PropTypes.string,
|
||||
label: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.element,
|
||||
]).isRequired,
|
||||
doc: PropTypes.string,
|
||||
action: PropTypes.element,
|
||||
children: PropTypes.node.isRequired,
|
||||
style: PropTypes.object,
|
||||
onChange: PropTypes.func,
|
||||
fieldSpec: PropTypes.object,
|
||||
wideMode: PropTypes.bool,
|
||||
error: PropTypes.array,
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showDoc: false,
|
||||
}
|
||||
}
|
||||
|
||||
onChange(e) {
|
||||
const value = e.target.value
|
||||
return this.props.onChange(value === "" ? null: value)
|
||||
return this.props.onChange(value === "" ? undefined : value)
|
||||
}
|
||||
|
||||
onToggleDoc = (val) => {
|
||||
this.setState({
|
||||
showDoc: val
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const errors = [].concat(this.props.error || []);
|
||||
|
||||
return <div style={this.props.style}
|
||||
data-wd-key={this.props["data-wd-key"]}
|
||||
className={classnames({
|
||||
"maputnik-input-block": true,
|
||||
"maputnik-input-block--wide": this.props.wideMode,
|
||||
"maputnik-action-block": this.props.action
|
||||
})}
|
||||
>
|
||||
{this.props.doc &&
|
||||
{this.props.fieldSpec &&
|
||||
<div className="maputnik-input-block-label">
|
||||
<DocLabel
|
||||
label={this.props.label}
|
||||
doc={this.props.doc}
|
||||
onToggleDoc={this.onToggleDoc}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{!this.props.doc &&
|
||||
{!this.props.fieldSpec &&
|
||||
<label className="maputnik-input-block-label">
|
||||
{this.props.label}
|
||||
</label>
|
||||
@@ -50,6 +73,21 @@ class InputBlock extends React.Component {
|
||||
<div className="maputnik-input-block-content">
|
||||
{this.props.children}
|
||||
</div>
|
||||
{errors.length > 0 &&
|
||||
<div className="maputnik-inline-error">
|
||||
{[].concat(this.props.error).map((error, idx) => {
|
||||
return <div key={idx}>{error.message}</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
{this.props.fieldSpec &&
|
||||
<div
|
||||
className="maputnik-doc-inline"
|
||||
style={{display: this.state.showDoc ? '' : 'none'}}
|
||||
>
|
||||
<SpecDoc fieldSpec={this.props.fieldSpec} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
let IDX = 0;
|
||||
|
||||
class NumberInput extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.number,
|
||||
@@ -8,31 +10,64 @@ class NumberInput extends React.Component {
|
||||
min: PropTypes.number,
|
||||
max: PropTypes.number,
|
||||
onChange: PropTypes.func,
|
||||
allowRange: PropTypes.bool,
|
||||
rangeStep: PropTypes.number,
|
||||
wdKey: PropTypes.string,
|
||||
required: PropTypes.bool,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
rangeStep: 1
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
value: props.value
|
||||
uuid: IDX++,
|
||||
editing: false,
|
||||
value: props.value,
|
||||
dirtyValue: props.value,
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.setState({ value: nextProps.value })
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (!state.editing && props.value !== state.value) {
|
||||
return {
|
||||
value: props.value,
|
||||
dirtyValue: props.value,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
changeValue(newValue) {
|
||||
const value = parseFloat(newValue)
|
||||
const value = (newValue === "" || newValue === undefined) ?
|
||||
undefined :
|
||||
parseFloat(newValue);
|
||||
|
||||
const hasChanged = this.state.value !== value
|
||||
const hasChanged = this.props.value !== value;
|
||||
if(this.isValid(value) && hasChanged) {
|
||||
this.props.onChange(value)
|
||||
} else {
|
||||
this.setState({ value: newValue })
|
||||
this.setState({
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
else if (!this.isValid(value) && hasChanged) {
|
||||
this.setState({
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
dirtyValue: newValue === "" ? undefined : newValue,
|
||||
})
|
||||
}
|
||||
|
||||
isValid(v) {
|
||||
if (v === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const value = parseFloat(v)
|
||||
if(isNaN(value)) {
|
||||
return false
|
||||
@@ -49,30 +84,149 @@ class NumberInput extends React.Component {
|
||||
return true
|
||||
}
|
||||
|
||||
resetValue() {
|
||||
resetValue = () => {
|
||||
this.setState({editing: false});
|
||||
// Reset explicitly to default value if value has been cleared
|
||||
if(this.state.value === "") {
|
||||
return this.changeValue(this.props.default)
|
||||
return;
|
||||
}
|
||||
|
||||
// If set value is invalid fall back to the last valid value from props or at last resort the default value
|
||||
if(!this.isValid(this.state.value)) {
|
||||
if (!this.isValid(this.state.value)) {
|
||||
if(this.isValid(this.props.value)) {
|
||||
this.changeValue(this.props.value)
|
||||
this.setState({dirtyValue: this.props.value});
|
||||
} else {
|
||||
this.changeValue(this.props.default)
|
||||
this.changeValue(undefined);
|
||||
this.setState({dirtyValue: undefined});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onChangeRange = (e) => {
|
||||
let value = parseFloat(e.target.value, 10);
|
||||
const step = this.props.rangeStep;
|
||||
let dirtyValue = value;
|
||||
|
||||
if(step) {
|
||||
// Can't do this with the <input/> range step attribute else we won't be able to set a high precision value via the text input.
|
||||
const snap = value % step;
|
||||
|
||||
// Round up/down to step
|
||||
if (this._keyboardEvent) {
|
||||
// If it's keyboard event we might get a low positive/negative value,
|
||||
// for example we might go from 13 to 13.23, however because we know
|
||||
// that came from a keyboard event we always want to increase by a
|
||||
// single step value.
|
||||
if (value < this.state.dirtyValue) {
|
||||
value = this.state.value - step;
|
||||
}
|
||||
else {
|
||||
value = this.state.value + step
|
||||
}
|
||||
dirtyValue = value;
|
||||
}
|
||||
else {
|
||||
if (snap < step/2) {
|
||||
value = value - snap;
|
||||
}
|
||||
else {
|
||||
value = value + (step - snap);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this._keyboardEvent = false;
|
||||
|
||||
// Clamp between min/max
|
||||
value = Math.max(this.props.min, Math.min(this.props.max, value));
|
||||
|
||||
this.setState({value, dirtyValue});
|
||||
this.props.onChange(value);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <input
|
||||
className="maputnik-number"
|
||||
placeholder={this.props.default}
|
||||
value={this.state.value}
|
||||
onChange={e => this.changeValue(e.target.value)}
|
||||
onBlur={this.resetValue.bind(this)}
|
||||
/>
|
||||
if(
|
||||
this.props.hasOwnProperty("min") && this.props.hasOwnProperty("max") &&
|
||||
this.props.min !== undefined && this.props.max !== undefined &&
|
||||
this.props.allowRange
|
||||
) {
|
||||
const value = this.state.editing ? this.state.dirtyValue : this.state.value;
|
||||
const defaultValue = this.props.default === undefined ? "" : this.props.default;
|
||||
let inputValue;
|
||||
if (this.state.editingRange) {
|
||||
inputValue = this.state.value;
|
||||
}
|
||||
else {
|
||||
inputValue = value;
|
||||
}
|
||||
|
||||
return <div className="maputnik-number-container">
|
||||
<input
|
||||
className="maputnik-number-range"
|
||||
key="range"
|
||||
type="range"
|
||||
max={this.props.max}
|
||||
min={this.props.min}
|
||||
step="any"
|
||||
spellCheck="false"
|
||||
value={value === undefined ? defaultValue : value}
|
||||
aria-hidden="true"
|
||||
onChange={this.onChangeRange}
|
||||
onKeyDown={() => {
|
||||
this._keyboardEvent = true;
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
this.setState({editing: true, editingRange: true});
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
// Safari doesn't get onBlur event
|
||||
this.setState({editing: false, editingRange: false});
|
||||
}}
|
||||
onBlur={() => {
|
||||
this.setState({
|
||||
editing: false,
|
||||
editingRange: false,
|
||||
dirtyValue: this.state.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
key="text"
|
||||
type="text"
|
||||
spellCheck="false"
|
||||
className="maputnik-number"
|
||||
placeholder={this.props.default}
|
||||
value={inputValue === undefined ? "" : inputValue}
|
||||
onFocus={e => {
|
||||
this.setState({editing: true});
|
||||
}}
|
||||
onChange={e => {
|
||||
this.changeValue(e.target.value);
|
||||
}}
|
||||
onBlur={e => {
|
||||
this.setState({editing: false});
|
||||
this.resetValue()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
else {
|
||||
const value = this.state.editing ? this.state.dirtyValue : this.state.value;
|
||||
|
||||
return <input
|
||||
spellCheck="false"
|
||||
className="maputnik-number"
|
||||
placeholder={this.props.default}
|
||||
value={value === undefined ? "" : value}
|
||||
onChange={e => this.changeValue(e.target.value)}
|
||||
onFocus={() => {
|
||||
this.setState({editing: true});
|
||||
}}
|
||||
onBlur={this.resetValue}
|
||||
required={this.props.required}
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import PropTypes from 'prop-types'
|
||||
class SelectInput extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
"data-wd-key": PropTypes.string,
|
||||
options: PropTypes.array.isRequired,
|
||||
style: PropTypes.object,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
title: PropTypes.string,
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +20,9 @@ class SelectInput extends React.Component {
|
||||
|
||||
return <select
|
||||
className="maputnik-select"
|
||||
data-wd-key={this.props["data-wd-key"]}
|
||||
style={this.props.style}
|
||||
title={this.props.title}
|
||||
value={this.props.value}
|
||||
onChange={e => this.props.onChange(e.target.value)}
|
||||
>
|
||||
|
||||
83
src/components/inputs/SpecDoc.js
Normal file
83
src/components/inputs/SpecDoc.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
export default class SpecDoc extends React.Component {
|
||||
static propTypes = {
|
||||
fieldSpec: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
render () {
|
||||
const {fieldSpec} = this.props;
|
||||
|
||||
const {doc, values} = fieldSpec;
|
||||
const sdkSupport = fieldSpec['sdk-support'];
|
||||
|
||||
const headers = {
|
||||
js: "JS",
|
||||
android: "Android",
|
||||
ios: "iOS",
|
||||
macos: "macOS",
|
||||
};
|
||||
|
||||
const renderValues = (
|
||||
!!values &&
|
||||
// HACK: Currently we merge additional values into the stylespec, so this is required
|
||||
// See <https://github.com/maputnik/editor/blob/master/src/components/fields/PropertyGroup.jsx#L16>
|
||||
!Array.isArray(values)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{doc &&
|
||||
<div className="SpecDoc">
|
||||
<div className="SpecDoc__doc">{doc}</div>
|
||||
{renderValues &&
|
||||
<ul className="SpecDoc__values">
|
||||
{Object.entries(values).map(([key, value]) => {
|
||||
return (
|
||||
<li key={key}>
|
||||
<code>{JSON.stringify(key)}</code>
|
||||
<div>{value.doc}</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
{sdkSupport &&
|
||||
<div className="SpecDoc__sdk-support">
|
||||
<table className="SpecDoc__sdk-support__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
{Object.values(headers).map(header => {
|
||||
return <th key={header}>{header}</th>;
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(sdkSupport).map(([key, supportObj]) => {
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>{key}</td>
|
||||
{Object.keys(headers).map(k => {
|
||||
const value = supportObj[k];
|
||||
if (supportObj.hasOwnProperty(k)) {
|
||||
return <td key={k}>{supportObj[k]}</td>;
|
||||
}
|
||||
else {
|
||||
return <td key={k}>no</td>;
|
||||
}
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,37 @@ import PropTypes from 'prop-types'
|
||||
|
||||
class StringInput extends React.Component {
|
||||
static propTypes = {
|
||||
"data-wd-key": PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
default: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
onInput: PropTypes.func,
|
||||
multi: PropTypes.bool,
|
||||
required: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
spellCheck: PropTypes.bool,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onInput: () => {},
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
editing: false,
|
||||
value: props.value || ''
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.setState({ value: nextProps.value || '' })
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (!state.editing) {
|
||||
return {
|
||||
value: props.value
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -39,15 +54,38 @@ class StringInput extends React.Component {
|
||||
]
|
||||
}
|
||||
|
||||
if(!!this.props.disabled) {
|
||||
classes.push("maputnik-string--disabled");
|
||||
}
|
||||
|
||||
return React.createElement(tag, {
|
||||
"data-wd-key": this.props["data-wd-key"],
|
||||
spellCheck: this.props.hasOwnProperty("spellCheck") ? this.props.spellCheck : !(tag === "input"),
|
||||
disabled: this.props.disabled,
|
||||
className: classes.join(" "),
|
||||
style: this.props.style,
|
||||
value: this.state.value,
|
||||
value: this.state.value === undefined ? "" : this.state.value,
|
||||
placeholder: this.props.default,
|
||||
onChange: e => this.setState({ value: e.target.value }),
|
||||
onChange: e => {
|
||||
this.setState({
|
||||
editing: true,
|
||||
value: e.target.value
|
||||
}, () => {
|
||||
this.props.onInput(this.state.value);
|
||||
});
|
||||
},
|
||||
onBlur: () => {
|
||||
if(this.state.value!==this.props.value) this.props.onChange(this.state.value)
|
||||
}
|
||||
if(this.state.value!==this.props.value) {
|
||||
this.setState({editing: false});
|
||||
this.props.onChange(this.state.value);
|
||||
}
|
||||
},
|
||||
onKeyDown: (e) => {
|
||||
if (e.keyCode === 13) {
|
||||
this.props.onChange(this.state.value);
|
||||
}
|
||||
},
|
||||
required: this.props.required,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
77
src/components/inputs/UrlInput.jsx
Normal file
77
src/components/inputs/UrlInput.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import StringInput from './StringInput'
|
||||
import SmallError from '../util/SmallError'
|
||||
|
||||
|
||||
function validate (url) {
|
||||
let error;
|
||||
const getProtocol = (url) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.protocol;
|
||||
}
|
||||
catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const protocol = getProtocol(url);
|
||||
if (
|
||||
protocol &&
|
||||
protocol === "http:" &&
|
||||
window.location.protocol === "https:"
|
||||
) {
|
||||
error = (
|
||||
<SmallError>
|
||||
CORS policy won't allow fetching resources served over http from https, use a <code>https://</code> domain
|
||||
</SmallError>
|
||||
);
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
class UrlInput extends React.Component {
|
||||
static propTypes = {
|
||||
"data-wd-key": PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
default: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
onInput: PropTypes.func,
|
||||
multi: PropTypes.bool,
|
||||
required: PropTypes.bool,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onInput: () => {},
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
error: validate(props.value)
|
||||
};
|
||||
}
|
||||
|
||||
onInput = (url) => {
|
||||
this.setState({
|
||||
error: validate(url)
|
||||
});
|
||||
this.props.onInput(url);
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div>
|
||||
<StringInput
|
||||
{...this.props}
|
||||
onInput={this.onInput}
|
||||
/>
|
||||
{this.state.error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UrlInput
|
||||
34
src/components/layers/Collapse.jsx
Normal file
34
src/components/layers/Collapse.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Collapse from 'react-collapse'
|
||||
import accessibility from '../../libs/accessibility'
|
||||
|
||||
|
||||
export default class CollapseAlt extends React.Component {
|
||||
static propTypes = {
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
children: PropTypes.element.isRequired
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
isActive: true
|
||||
}
|
||||
|
||||
render() {
|
||||
if (accessibility.reducedMotionEnabled()) {
|
||||
return (
|
||||
<div style={{display: this.props.isActive ? "block" : "none"}}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
else {
|
||||
return (
|
||||
<Collapse isOpened={this.props.isActive}>
|
||||
{this.props.children}
|
||||
</Collapse>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import CollapseOpenIcon from 'react-icons/lib/md/arrow-drop-down'
|
||||
import CollapseCloseIcon from 'react-icons/lib/md/arrow-drop-up'
|
||||
import {MdArrowDropDown, MdArrowDropUp} from 'react-icons/md'
|
||||
|
||||
export default class Collapser extends React.Component {
|
||||
static propTypes = {
|
||||
@@ -15,7 +14,7 @@ export default class Collapser extends React.Component {
|
||||
height: 20,
|
||||
...this.props.style,
|
||||
}
|
||||
return this.props.isCollapsed ? <CollapseCloseIcon style={iconStyle}/> : <CollapseOpenIcon style={iconStyle} />
|
||||
return this.props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,15 @@ class MetadataBlock extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
return <InputBlock label={"Comments"} doc={"Comments for the current layer. This is non-standard and not in the spec."}>
|
||||
const fieldSpec = {
|
||||
doc: "Comments for the current layer. This is non-standard and not in the spec."
|
||||
};
|
||||
|
||||
return <InputBlock
|
||||
label={"Comments"}
|
||||
fieldSpec={fieldSpec}
|
||||
data-wd-key="layer-comment"
|
||||
>
|
||||
<StringInput
|
||||
multi={true}
|
||||
value={this.props.value}
|
||||
|
||||
@@ -1,89 +1,159 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import classnames from 'classnames';
|
||||
|
||||
import {Controlled as CodeMirror} from 'react-codemirror2'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import StringInput from '../inputs/StringInput'
|
||||
import SelectInput from '../inputs/SelectInput'
|
||||
import CodeMirror from 'codemirror';
|
||||
|
||||
import 'codemirror/mode/javascript/javascript'
|
||||
import 'codemirror/addon/lint/lint'
|
||||
import 'codemirror/addon/edit/matchbrackets'
|
||||
import 'codemirror/lib/codemirror.css'
|
||||
import 'codemirror/addon/lint/lint.css'
|
||||
import '../../codemirror-maputnik.css'
|
||||
import jsonlint from 'jsonlint'
|
||||
|
||||
// This is mainly because of this issue <https://github.com/zaach/jsonlint/issues/57> also the API has changed, see comment in file
|
||||
import '../../vendor/codemirror/addon/lint/json-lint'
|
||||
import stringifyPretty from 'json-stringify-pretty-compact'
|
||||
import '../util/codemirror-mgl';
|
||||
|
||||
|
||||
class JSONEditor extends React.Component {
|
||||
static propTypes = {
|
||||
layer: PropTypes.object.isRequired,
|
||||
layer: PropTypes.any.isRequired,
|
||||
maxHeight: PropTypes.number,
|
||||
onChange: PropTypes.func,
|
||||
lineNumbers: PropTypes.bool,
|
||||
lineWrapping: PropTypes.bool,
|
||||
getValue: PropTypes.func,
|
||||
gutters: PropTypes.array,
|
||||
className: PropTypes.string,
|
||||
onFocus: PropTypes.func,
|
||||
onBlur: PropTypes.func,
|
||||
onJSONValid: PropTypes.func,
|
||||
onJSONInvalid: PropTypes.func,
|
||||
mode: PropTypes.object,
|
||||
lint: PropTypes.oneOfType([
|
||||
PropTypes.bool,
|
||||
PropTypes.object,
|
||||
]),
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
lineNumbers: true,
|
||||
lineWrapping: false,
|
||||
gutters: ["CodeMirror-lint-markers"],
|
||||
getValue: (data) => {
|
||||
return stringifyPretty(data, {indent: 2, maxLength: 40});
|
||||
},
|
||||
onFocus: () => {},
|
||||
onBlur: () => {},
|
||||
onJSONInvalid: () => {},
|
||||
onJSONValid: () => {},
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
code: JSON.stringify(props.layer, null, 2)
|
||||
}
|
||||
isEditing: false,
|
||||
prevValue: this.props.getValue(this.props.layer),
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.setState({
|
||||
code: JSON.stringify(nextProps.layer, null, 2)
|
||||
})
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
try {
|
||||
const parsedLayer = JSON.parse(this.state.code)
|
||||
// If the structure is still the same do not update
|
||||
// because it affects editing experience by reformatting all the time
|
||||
return nextState.code !== JSON.stringify(parsedLayer, null, 2)
|
||||
} catch(err) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
onCodeUpdate(newCode) {
|
||||
try {
|
||||
const parsedLayer = JSON.parse(newCode)
|
||||
this.props.onChange(parsedLayer)
|
||||
} catch(err) {
|
||||
console.warn(err)
|
||||
} finally {
|
||||
this.setState({
|
||||
code: newCode
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
resetValue() {
|
||||
console.log('reset')
|
||||
this.setState({
|
||||
code: JSON.stringify(this.props.layer, null, 2)
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const codeMirrorOptions = {
|
||||
mode: {name: "javascript", json: true},
|
||||
componentDidMount () {
|
||||
this._doc = CodeMirror(this._el, {
|
||||
value: this.props.getValue(this.props.layer),
|
||||
mode: this.props.mode || {
|
||||
name: "mgl",
|
||||
},
|
||||
lineWrapping: this.props.lineWrapping,
|
||||
tabSize: 2,
|
||||
theme: 'maputnik',
|
||||
viewportMargin: Infinity,
|
||||
lineNumbers: true,
|
||||
lint: true,
|
||||
gutters: ["CodeMirror-lint-markers"],
|
||||
lineNumbers: this.props.lineNumbers,
|
||||
lint: this.props.lint || {
|
||||
context: "layer"
|
||||
},
|
||||
matchBrackets: true,
|
||||
gutters: this.props.gutters,
|
||||
scrollbarStyle: "null",
|
||||
});
|
||||
|
||||
this._doc.on('change', this.onChange);
|
||||
this._doc.on('focus', this.onFocus);
|
||||
this._doc.on('blur', this.onBlur);
|
||||
}
|
||||
|
||||
onFocus = () => {
|
||||
this.props.onFocus();
|
||||
this.setState({
|
||||
isEditing: true
|
||||
});
|
||||
}
|
||||
|
||||
onBlur = () => {
|
||||
this.props.onBlur();
|
||||
this.setState({
|
||||
isEditing: false
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnMount () {
|
||||
this._doc.off('change', this.onChange);
|
||||
this._doc.off('focus', this.onFocus);
|
||||
this._doc.off('blur', this.onBlur);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!this.state.isEditing && prevProps.layer !== this.props.layer) {
|
||||
this._cancelNextChange = true;
|
||||
this._doc.setValue(
|
||||
this.props.getValue(this.props.layer),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onChange = (e) => {
|
||||
if (this._cancelNextChange) {
|
||||
this._cancelNextChange = false;
|
||||
this.setState({
|
||||
prevValue: this._doc.getValue(),
|
||||
})
|
||||
return;
|
||||
}
|
||||
const newCode = this._doc.getValue();
|
||||
|
||||
if (this.state.prevValue !== newCode) {
|
||||
let parsedLayer, err;
|
||||
try {
|
||||
parsedLayer = JSON.parse(newCode);
|
||||
} catch(_err) {
|
||||
err = _err;
|
||||
console.warn(_err)
|
||||
}
|
||||
|
||||
if (err) {
|
||||
this.props.onJSONInvalid();
|
||||
}
|
||||
else {
|
||||
this.props.onChange(parsedLayer)
|
||||
this.props.onJSONValid();
|
||||
}
|
||||
}
|
||||
|
||||
return <CodeMirror
|
||||
value={this.state.code}
|
||||
onBeforeChange={(editor, data, value) => this.onCodeUpdate(value)}
|
||||
onFocusChange={focused => focused ? true : this.resetValue()}
|
||||
options={codeMirrorOptions}
|
||||
this.setState({
|
||||
prevValue: newCode,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const style = {};
|
||||
if (this.props.maxHeight) {
|
||||
style.maxHeight = this.props.maxHeight;
|
||||
}
|
||||
|
||||
return <div
|
||||
className={classnames("codemirror-container", this.props.className)}
|
||||
ref={(el) => this._el = el}
|
||||
style={style}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Wrapper, Button, Menu, MenuItem } from 'react-aria-menubutton'
|
||||
|
||||
import JSONEditor from './JSONEditor'
|
||||
import FilterEditor from '../filter/FilterEditor'
|
||||
@@ -12,17 +13,17 @@ import MaxZoomBlock from './MaxZoomBlock'
|
||||
import CommentBlock from './CommentBlock'
|
||||
import LayerSourceBlock from './LayerSourceBlock'
|
||||
import LayerSourceLayerBlock from './LayerSourceLayerBlock'
|
||||
import {Accordion} from 'react-accessible-accordion';
|
||||
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import MultiButtonInput from '../inputs/MultiButtonInput'
|
||||
import {MdMoreVert} from 'react-icons/md'
|
||||
|
||||
import { changeType, changeProperty } from '../../libs/layer'
|
||||
import layout from '../../config/layout.json'
|
||||
import {formatLayerId} from '../util/format';
|
||||
|
||||
class UnsupportedLayer extends React.Component {
|
||||
render() {
|
||||
return <div></div>
|
||||
}
|
||||
|
||||
function getLayoutForType (type) {
|
||||
return layout[type] ? layout[type] : layout.invalid;
|
||||
}
|
||||
|
||||
function layoutGroups(layerType) {
|
||||
@@ -38,7 +39,9 @@ function layoutGroups(layerType) {
|
||||
title: 'JSON Editor',
|
||||
type: 'jsoneditor'
|
||||
}
|
||||
return [layerGroup, filterGroup].concat(layout[layerType].groups).concat([editorGroup])
|
||||
return [layerGroup, filterGroup]
|
||||
.concat(getLayoutForType(layerType).groups)
|
||||
.concat([editorGroup])
|
||||
}
|
||||
|
||||
/** Layer editor supporting multiple types of layers. */
|
||||
@@ -50,6 +53,14 @@ export default class LayerEditor extends React.Component {
|
||||
spec: PropTypes.object.isRequired,
|
||||
onLayerChanged: PropTypes.func,
|
||||
onLayerIdChange: PropTypes.func,
|
||||
onMoveLayer: PropTypes.func,
|
||||
onLayerDestroy: PropTypes.func,
|
||||
onLayerCopy: PropTypes.func,
|
||||
onLayerVisibilityToggle: PropTypes.func,
|
||||
isFirstLayer: PropTypes.bool,
|
||||
isLastLayer: PropTypes.bool,
|
||||
layerIndex: PropTypes.number,
|
||||
errors: PropTypes.array,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
@@ -74,18 +85,18 @@ export default class LayerEditor extends React.Component {
|
||||
this.state = { editorGroups }
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const additionalGroups = { ...this.state.editorGroups }
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
const additionalGroups = { ...state.editorGroups }
|
||||
|
||||
layout[nextProps.layer.type].groups.forEach(group => {
|
||||
getLayoutForType(props.layer.type).groups.forEach(group => {
|
||||
if(!(group.title in additionalGroups)) {
|
||||
additionalGroups[group.title] = true
|
||||
}
|
||||
})
|
||||
|
||||
this.setState({
|
||||
return {
|
||||
editorGroups: additionalGroups
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
getChildContext () {
|
||||
@@ -98,7 +109,10 @@ export default class LayerEditor extends React.Component {
|
||||
}
|
||||
|
||||
changeProperty(group, property, newValue) {
|
||||
this.props.onLayerChanged(changeProperty(this.props.layer, group, property, newValue))
|
||||
this.props.onLayerChanged(
|
||||
this.props.layerIndex,
|
||||
changeProperty(this.props.layer, group, property, newValue)
|
||||
)
|
||||
}
|
||||
|
||||
onGroupToggle(groupTitle, active) {
|
||||
@@ -116,38 +130,70 @@ export default class LayerEditor extends React.Component {
|
||||
if(this.props.layer.metadata) {
|
||||
comment = this.props.layer.metadata['maputnik:comment']
|
||||
}
|
||||
const {errors, layerIndex} = this.props;
|
||||
|
||||
const errorData = {};
|
||||
errors.forEach(error => {
|
||||
if (
|
||||
error.parsed &&
|
||||
error.parsed.type === "layer" &&
|
||||
error.parsed.data.index == layerIndex
|
||||
) {
|
||||
errorData[error.parsed.data.key] = {
|
||||
message: error.parsed.data.message
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
let sourceLayerIds;
|
||||
if(this.props.sources.hasOwnProperty(this.props.layer.source)) {
|
||||
sourceLayerIds = this.props.sources[this.props.layer.source].layers;
|
||||
}
|
||||
|
||||
switch(type) {
|
||||
case 'layer': return <div>
|
||||
<LayerIdBlock
|
||||
value={this.props.layer.id}
|
||||
onChange={newId => this.props.onLayerIdChange(this.props.layer.id, newId)}
|
||||
wdKey="layer-editor.layer-id"
|
||||
error={errorData.id}
|
||||
onChange={newId => this.props.onLayerIdChange(this.props.layerIndex, this.props.layer.id, newId)}
|
||||
/>
|
||||
<LayerTypeBlock
|
||||
disabled={true}
|
||||
error={errorData.type}
|
||||
value={this.props.layer.type}
|
||||
onChange={newType => this.props.onLayerChanged(changeType(this.props.layer, newType))}
|
||||
onChange={newType => this.props.onLayerChanged(
|
||||
this.props.layerIndex,
|
||||
changeType(this.props.layer, newType)
|
||||
)}
|
||||
/>
|
||||
{this.props.layer.type !== 'background' && <LayerSourceBlock
|
||||
error={errorData.sources}
|
||||
sourceIds={Object.keys(this.props.sources)}
|
||||
value={this.props.layer.source}
|
||||
onChange={v => this.changeProperty(null, 'source', v)}
|
||||
/>
|
||||
}
|
||||
{this.props.layer.type !== 'raster' && this.props.layer.type !== 'background' && <LayerSourceLayerBlock
|
||||
sourceLayerIds={this.props.sources[this.props.layer.source].layers}
|
||||
{['background', 'raster', 'hillshade', 'heatmap'].indexOf(this.props.layer.type) < 0 &&
|
||||
<LayerSourceLayerBlock
|
||||
error={errorData['source-layer']}
|
||||
sourceLayerIds={sourceLayerIds}
|
||||
value={this.props.layer['source-layer']}
|
||||
onChange={v => this.changeProperty(null, 'source-layer', v)}
|
||||
/>
|
||||
}
|
||||
<MinZoomBlock
|
||||
error={errorData.minzoom}
|
||||
value={this.props.layer.minzoom}
|
||||
onChange={v => this.changeProperty(null, 'minzoom', v)}
|
||||
/>
|
||||
<MaxZoomBlock
|
||||
error={errorData.maxzoom}
|
||||
value={this.props.layer.maxzoom}
|
||||
onChange={v => this.changeProperty(null, 'maxzoom', v)}
|
||||
/>
|
||||
<CommentBlock
|
||||
error={errorData.comment}
|
||||
value={comment}
|
||||
onChange={v => this.changeProperty('metadata', 'maputnik:comment', v == "" ? undefined : v)}
|
||||
/>
|
||||
@@ -155,6 +201,7 @@ export default class LayerEditor extends React.Component {
|
||||
case 'filter': return <div>
|
||||
<div className="maputnik-filter-editor-wrapper">
|
||||
<FilterEditor
|
||||
errors={errorData}
|
||||
filter={this.props.layer.filter}
|
||||
properties={this.props.vectorLayers[this.props.layer['source-layer']]}
|
||||
onChange={f => this.changeProperty(null, 'filter', f)}
|
||||
@@ -162,6 +209,7 @@ export default class LayerEditor extends React.Component {
|
||||
</div>
|
||||
</div>
|
||||
case 'properties': return <PropertyGroup
|
||||
errors={errorData}
|
||||
layer={this.props.layer}
|
||||
groupFields={fields}
|
||||
spec={this.props.spec}
|
||||
@@ -169,18 +217,34 @@ export default class LayerEditor extends React.Component {
|
||||
/>
|
||||
case 'jsoneditor': return <JSONEditor
|
||||
layer={this.props.layer}
|
||||
onChange={this.props.onLayerChanged}
|
||||
onChange={(layer) => {
|
||||
this.props.onLayerChanged(
|
||||
this.props.layerIndex,
|
||||
layer
|
||||
);
|
||||
}}
|
||||
/>
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
moveLayer(offset) {
|
||||
this.props.onMoveLayer({
|
||||
oldIndex: this.props.layerIndex,
|
||||
newIndex: this.props.layerIndex+offset
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const groupIds = [];
|
||||
const layerType = this.props.layer.type
|
||||
const groups = layoutGroups(layerType).filter(group => {
|
||||
return !(layerType === 'background' && group.type === 'source')
|
||||
}).map(group => {
|
||||
const groupId = group.title.replace(/ /g, "_");
|
||||
groupIds.push(groupId);
|
||||
return <LayerEditorGroup
|
||||
data-wd-key={group.title}
|
||||
id={groupId}
|
||||
key={group.title}
|
||||
title={group.title}
|
||||
isActive={this.state.editorGroups[group.title]}
|
||||
@@ -190,9 +254,80 @@ export default class LayerEditor extends React.Component {
|
||||
</LayerEditorGroup>
|
||||
})
|
||||
|
||||
const layout = this.props.layer.layout || {}
|
||||
|
||||
const items = {
|
||||
delete: {
|
||||
text: "Delete",
|
||||
handler: () => this.props.onLayerDestroy(this.props.layerIndex)
|
||||
},
|
||||
duplicate: {
|
||||
text: "Duplicate",
|
||||
handler: () => this.props.onLayerCopy(this.props.layerIndex)
|
||||
},
|
||||
hide: {
|
||||
text: (layout.visibility === "none") ? "Show" : "Hide",
|
||||
handler: () => this.props.onLayerVisibilityToggle(this.props.layerIndex)
|
||||
},
|
||||
moveLayerUp: {
|
||||
text: "Move layer up",
|
||||
// Not actually used...
|
||||
disabled: this.props.isFirstLayer,
|
||||
handler: () => this.moveLayer(-1)
|
||||
},
|
||||
moveLayerDown: {
|
||||
text: "Move layer down",
|
||||
// Not actually used...
|
||||
disabled: this.props.isLastLayer,
|
||||
handler: () => this.moveLayer(+1)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelection(id, event) {
|
||||
event.stopPropagation;
|
||||
items[id].handler();
|
||||
}
|
||||
|
||||
return <div className="maputnik-layer-editor"
|
||||
>
|
||||
{groups}
|
||||
<header>
|
||||
<div className="layer-header">
|
||||
<h2 className="layer-header__title">
|
||||
Layer: {formatLayerId(this.props.layer.id)}
|
||||
</h2>
|
||||
<div className="layer-header__info">
|
||||
<Wrapper
|
||||
className='more-menu'
|
||||
onSelection={handleSelection}
|
||||
closeOnSelection={false}
|
||||
>
|
||||
<Button className='more-menu__button'>
|
||||
<MdMoreVert className="more-menu__button__svg" />
|
||||
</Button>
|
||||
<Menu>
|
||||
<ul className="more-menu__menu">
|
||||
{Object.keys(items).map((id, idx) => {
|
||||
const item = items[id];
|
||||
return <li key={id}>
|
||||
<MenuItem value={id} className='more-menu__menu__item'>
|
||||
{item.text}
|
||||
</MenuItem>
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
</Menu>
|
||||
</Wrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
<Accordion
|
||||
allowMultipleExpanded={true}
|
||||
allowZeroExpanded={true}
|
||||
preExpanded={groupIds}
|
||||
>
|
||||
{groups}
|
||||
</Accordion>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Collapse from 'react-collapse'
|
||||
import Collapser from './Collapser'
|
||||
import Icon from '@mdi/react'
|
||||
import {
|
||||
mdiMenuDown,
|
||||
mdiMenuUp
|
||||
} from '@mdi/js';
|
||||
import {
|
||||
AccordionItem,
|
||||
AccordionItemHeading,
|
||||
AccordionItemButton,
|
||||
AccordionItemPanel,
|
||||
} from 'react-accessible-accordion';
|
||||
|
||||
|
||||
export default class LayerEditorGroup extends React.Component {
|
||||
static propTypes = {
|
||||
"id": PropTypes.string,
|
||||
"data-wd-key": PropTypes.string,
|
||||
title: PropTypes.string.isRequired,
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
children: PropTypes.element.isRequired,
|
||||
@@ -12,17 +24,28 @@ export default class LayerEditorGroup extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>
|
||||
<div className="maputnik-layer-editor-group"
|
||||
return <AccordionItem uuid={this.props.id}>
|
||||
<AccordionItemHeading className="maputnik-layer-editor-group"
|
||||
data-wd-key={"layer-editor-group:"+this.props["data-wd-key"]}
|
||||
onClick={e => this.props.onActiveToggle(!this.props.isActive)}
|
||||
>
|
||||
<span>{this.props.title}</span>
|
||||
<span style={{flexGrow: 1}} />
|
||||
<Collapser isCollapsed={this.props.isActive} />
|
||||
</div>
|
||||
<Collapse isOpened={this.props.isActive}>
|
||||
<AccordionItemButton className="maputnik-layer-editor-group__button">
|
||||
<span style={{flexGrow: 1}}>{this.props.title}</span>
|
||||
<Icon
|
||||
path={mdiMenuUp}
|
||||
size={1}
|
||||
className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--up"
|
||||
/>
|
||||
<Icon
|
||||
path={mdiMenuDown}
|
||||
size={1}
|
||||
className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--down"
|
||||
/>
|
||||
</AccordionItemButton>
|
||||
</AccordionItemHeading>
|
||||
<AccordionItemPanel>
|
||||
{this.props.children}
|
||||
</Collapse>
|
||||
</div>
|
||||
</AccordionItemPanel>
|
||||
</AccordionItem>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import styleSpec from '@mapbox/mapbox-gl-style-spec/style-spec'
|
||||
import {latest} from '@mapbox/mapbox-gl-style-spec'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import StringInput from '../inputs/StringInput'
|
||||
|
||||
class LayerIdBlock extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
wdKey: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
error: PropTypes.object,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <InputBlock label={"ID"} doc={styleSpec.latest.layer.id.doc}>
|
||||
return <InputBlock label={"ID"} fieldSpec={latest.layer.id}
|
||||
data-wd-key={this.props.wdKey}
|
||||
error={this.props.error}
|
||||
>
|
||||
<StringInput
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import classnames from 'classnames'
|
||||
import cloneDeep from 'lodash.clonedeep'
|
||||
import lodash from 'lodash';
|
||||
|
||||
import Button from '../Button'
|
||||
import LayerListGroup from './LayerListGroup'
|
||||
import LayerListItem from './LayerListItem'
|
||||
import AddIcon from 'react-icons/lib/md/add-circle-outline'
|
||||
import AddModal from '../modals/AddModal'
|
||||
|
||||
import style from '../../libs/style.js'
|
||||
import {SortableContainer, SortableHandle, arrayMove} from 'react-sortable-hoc';
|
||||
import {SortableContainer} from 'react-sortable-hoc';
|
||||
|
||||
const layerListPropTypes = {
|
||||
layers: PropTypes.array.isRequired,
|
||||
@@ -39,7 +36,6 @@ function findClosestCommonPrefix(layers, idx) {
|
||||
}
|
||||
|
||||
// List of collapsible layer editors
|
||||
@SortableContainer
|
||||
class LayerListContainer extends React.Component {
|
||||
static propTypes = {...layerListPropTypes}
|
||||
static defaultProps = {
|
||||
@@ -47,7 +43,9 @@ class LayerListContainer extends React.Component {
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
super(props);
|
||||
this.selectedItemRef = React.createRef();
|
||||
this.scrollContainerRef = React.createRef();
|
||||
this.state = {
|
||||
collapsedGroups: {},
|
||||
areAllGroupsExpanded: false,
|
||||
@@ -57,36 +55,6 @@ class LayerListContainer extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
onLayerDestroy(layerId) {
|
||||
const remainingLayers = this.props.layers.slice(0)
|
||||
const idx = style.indexOfLayer(remainingLayers, layerId)
|
||||
remainingLayers.splice(idx, 1);
|
||||
this.props.onLayersChange(remainingLayers)
|
||||
}
|
||||
|
||||
onLayerCopy(layerId) {
|
||||
const changedLayers = this.props.layers.slice(0)
|
||||
const idx = style.indexOfLayer(changedLayers, layerId)
|
||||
|
||||
const clonedLayer = cloneDeep(changedLayers[idx])
|
||||
clonedLayer.id = clonedLayer.id + "-copy"
|
||||
changedLayers.splice(idx, 0, clonedLayer)
|
||||
this.props.onLayersChange(changedLayers)
|
||||
}
|
||||
|
||||
onLayerVisibilityToggle(layerId) {
|
||||
const changedLayers = this.props.layers.slice(0)
|
||||
const idx = style.indexOfLayer(changedLayers, layerId)
|
||||
|
||||
const layer = { ...changedLayers[idx] }
|
||||
const changedLayout = 'layout' in layer ? {...layer.layout} : {}
|
||||
changedLayout.visibility = changedLayout.visibility === 'none' ? 'visible' : 'none'
|
||||
|
||||
layer.layout = changedLayout
|
||||
changedLayers[idx] = layer
|
||||
this.props.onLayersChange(changedLayers)
|
||||
}
|
||||
|
||||
toggleModal(modalName) {
|
||||
this.setState({
|
||||
isOpen: {
|
||||
@@ -96,7 +64,7 @@ class LayerListContainer extends React.Component {
|
||||
})
|
||||
}
|
||||
|
||||
toggleLayers() {
|
||||
toggleLayers = () => {
|
||||
let idx=0
|
||||
|
||||
let newGroups=[]
|
||||
@@ -104,12 +72,12 @@ class LayerListContainer extends React.Component {
|
||||
this.groupedLayers().forEach(layers => {
|
||||
const groupPrefix = layerPrefix(layers[0].id)
|
||||
const lookupKey = [groupPrefix, idx].join('-')
|
||||
|
||||
|
||||
|
||||
if (layers.length > 1) {
|
||||
newGroups[lookupKey] = this.state.areAllGroupsExpanded
|
||||
}
|
||||
|
||||
|
||||
layers.forEach((layer) => {
|
||||
idx += 1
|
||||
})
|
||||
@@ -154,15 +122,84 @@ class LayerListContainer extends React.Component {
|
||||
return collapsed === undefined ? true : collapsed
|
||||
}
|
||||
|
||||
shouldComponentUpdate (nextProps, nextState) {
|
||||
// Always update on state change
|
||||
if (this.state !== nextState) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// This component tree only requires id and visibility from the layers
|
||||
// objects
|
||||
function getRequiredProps (layer) {
|
||||
const out = {
|
||||
id: layer.id,
|
||||
};
|
||||
|
||||
if (layer.layout) {
|
||||
out.layout = {
|
||||
visibility: layer.layout.visibility
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const layersEqual = lodash.isEqual(
|
||||
nextProps.layers.map(getRequiredProps),
|
||||
this.props.layers.map(getRequiredProps),
|
||||
);
|
||||
|
||||
function withoutLayers (props) {
|
||||
const out = {
|
||||
...props
|
||||
};
|
||||
delete out['layers'];
|
||||
return out;
|
||||
}
|
||||
|
||||
// Compare the props without layers because we've already compared them
|
||||
// efficiently above.
|
||||
const propsEqual = lodash.isEqual(
|
||||
withoutLayers(this.props),
|
||||
withoutLayers(nextProps)
|
||||
);
|
||||
|
||||
const propsChanged = !(layersEqual && propsEqual);
|
||||
return propsChanged;
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.selectedLayerIndex !== this.props.selectedLayerIndex) {
|
||||
const selectedItemNode = this.selectedItemRef.current;
|
||||
if (selectedItemNode && selectedItemNode.node) {
|
||||
const target = selectedItemNode.node;
|
||||
const options = {
|
||||
root: this.scrollContainerRef.current,
|
||||
threshold: 1.0
|
||||
}
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
observer.unobserve(target);
|
||||
if (entries.length > 0 && entries[0].intersectionRatio < 1) {
|
||||
target.scrollIntoView();
|
||||
}
|
||||
}, options);
|
||||
|
||||
observer.observe(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
const listItems = []
|
||||
let idx = 0
|
||||
this.groupedLayers().forEach(layers => {
|
||||
const layerIdCount = new Map();
|
||||
|
||||
const layersByGroup = this.groupedLayers();
|
||||
layersByGroup.forEach(layers => {
|
||||
const groupPrefix = layerPrefix(layers[0].id)
|
||||
if(layers.length > 1) {
|
||||
const grp = <LayerListGroup
|
||||
key={[groupPrefix, idx].join('-')}
|
||||
data-wd-key={[groupPrefix, idx].join('-')}
|
||||
key={`group-${groupPrefix}-${idx}`}
|
||||
title={groupPrefix}
|
||||
isActive={!this.isCollapsed(groupPrefix, idx) || idx === this.props.selectedLayerIndex}
|
||||
onActiveToggle={this.toggleLayerGroup.bind(this, groupPrefix, idx)}
|
||||
@@ -173,28 +210,48 @@ class LayerListContainer extends React.Component {
|
||||
layers.forEach((layer, idxInGroup) => {
|
||||
const groupIdx = findClosestCommonPrefix(this.props.layers, idx)
|
||||
|
||||
const layerError = this.props.errors.find(error => {
|
||||
return (
|
||||
error.parsed &&
|
||||
error.parsed.type === "layer" &&
|
||||
error.parsed.data.index == idx
|
||||
);
|
||||
});
|
||||
|
||||
const additionalProps = {};
|
||||
if (idx === this.props.selectedLayerIndex) {
|
||||
additionalProps.ref = this.selectedItemRef;
|
||||
}
|
||||
|
||||
layerIdCount.set(layer.id,
|
||||
layerIdCount.has(layer.id) ? layerIdCount.get(layer.id) + 1 : 0
|
||||
);
|
||||
const key = `${layer.id}-${layerIdCount.get(layer.id)}`;
|
||||
const listItem = <LayerListItem
|
||||
className={classnames({
|
||||
'maputnik-layer-list-item-collapsed': layers.length > 1 && this.isCollapsed(groupPrefix, groupIdx) && idx !== this.props.selectedLayerIndex,
|
||||
'maputnik-layer-list-item-group-last': idxInGroup == layers.length - 1 && layers.length > 1
|
||||
'maputnik-layer-list-item-group-last': idxInGroup == layers.length - 1 && layers.length > 1,
|
||||
'maputnik-layer-list-item--error': !!layerError
|
||||
})}
|
||||
index={idx}
|
||||
key={layer.id}
|
||||
key={key}
|
||||
layerId={layer.id}
|
||||
layerIndex={idx}
|
||||
layerType={layer.type}
|
||||
visibility={(layer.layout || {}).visibility}
|
||||
isSelected={idx === this.props.selectedLayerIndex}
|
||||
onLayerSelect={this.props.onLayerSelect}
|
||||
onLayerDestroy={this.onLayerDestroy.bind(this)}
|
||||
onLayerCopy={this.onLayerCopy.bind(this)}
|
||||
onLayerVisibilityToggle={this.onLayerVisibilityToggle.bind(this)}
|
||||
onLayerDestroy={this.props.onLayerDestroy.bind(this)}
|
||||
onLayerCopy={this.props.onLayerCopy.bind(this)}
|
||||
onLayerVisibilityToggle={this.props.onLayerVisibilityToggle.bind(this)}
|
||||
{...additionalProps}
|
||||
/>
|
||||
listItems.push(listItem)
|
||||
idx += 1
|
||||
})
|
||||
})
|
||||
|
||||
return <div className="maputnik-layer-list">
|
||||
return <div className="maputnik-layer-list" ref={this.scrollContainerRef}>
|
||||
<AddModal
|
||||
layers={this.props.layers}
|
||||
sources={this.props.sources}
|
||||
@@ -207,20 +264,22 @@ class LayerListContainer extends React.Component {
|
||||
<span className="maputnik-space" />
|
||||
<div className="maputnik-default-property">
|
||||
<div className="maputnik-multibutton">
|
||||
<a
|
||||
onClick={this.toggleLayers.bind(this)}
|
||||
<button
|
||||
id="skip-menu"
|
||||
onClick={this.toggleLayers}
|
||||
className="maputnik-button">
|
||||
{this.state.areAllGroupsExpanded === true ? "Collapse" : "Expand"}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="maputnik-default-property">
|
||||
<div className="maputnik-multibutton">
|
||||
<a
|
||||
<button
|
||||
onClick={this.toggleModal.bind(this, 'add')}
|
||||
data-wd-key="layer-list:add-layer"
|
||||
className="maputnik-button maputnik-button-selected">
|
||||
Add Layer
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -231,21 +290,16 @@ class LayerListContainer extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
const LayerListContainerSortable = SortableContainer((props) => <LayerListContainer {...props} />)
|
||||
|
||||
export default class LayerList extends React.Component {
|
||||
static propTypes = {...layerListPropTypes}
|
||||
|
||||
onSortEnd(move) {
|
||||
const { oldIndex, newIndex } = move
|
||||
if(oldIndex === newIndex) return
|
||||
let layers = this.props.layers.slice(0)
|
||||
layers = arrayMove(layers, oldIndex, newIndex)
|
||||
this.props.onLayersChange(layers)
|
||||
}
|
||||
|
||||
render() {
|
||||
return <LayerListContainer
|
||||
return <LayerListContainerSortable
|
||||
{...this.props}
|
||||
onSortEnd={this.onSortEnd.bind(this)}
|
||||
helperClass='sortableHelper'
|
||||
onSortEnd={this.props.onMoveLayer.bind(this)}
|
||||
useDragHandle={true}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Collapser from './Collapser'
|
||||
export default class LayerListGroup extends React.Component {
|
||||
static propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
"data-wd-key": PropTypes.string,
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
onActiveToggle: PropTypes.func.isRequired
|
||||
}
|
||||
@@ -12,6 +13,7 @@ export default class LayerListGroup extends React.Component {
|
||||
render() {
|
||||
return <li className="maputnik-layer-list-group">
|
||||
<div className="maputnik-layer-list-group-header"
|
||||
data-wd-key={"layer-list-group:"+this.props["data-wd-key"]}
|
||||
onClick={e => this.props.onActiveToggle(!this.props.isActive)}
|
||||
>
|
||||
<span className="maputnik-layer-list-group-title">{this.props.title}</span>
|
||||
|
||||
@@ -1,63 +1,68 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Color from 'color'
|
||||
import classnames from 'classnames'
|
||||
|
||||
import CopyIcon from 'react-icons/lib/md/content-copy'
|
||||
import VisibilityIcon from 'react-icons/lib/md/visibility'
|
||||
import VisibilityOffIcon from 'react-icons/lib/md/visibility-off'
|
||||
import DeleteIcon from 'react-icons/lib/md/delete'
|
||||
import {MdContentCopy, MdVisibility, MdVisibilityOff, MdDelete} from 'react-icons/md'
|
||||
|
||||
import LayerIcon from '../icons/LayerIcon'
|
||||
import LayerEditor from './LayerEditor'
|
||||
import {SortableElement, SortableHandle} from 'react-sortable-hoc'
|
||||
|
||||
@SortableHandle
|
||||
class LayerTypeDragHandle extends React.Component {
|
||||
static propTypes = LayerIcon.propTypes
|
||||
|
||||
render() {
|
||||
return <LayerIcon
|
||||
{...this.props}
|
||||
style={{
|
||||
cursor: 'move',
|
||||
width: 14,
|
||||
height: 14,
|
||||
paddingRight: 3,
|
||||
}}
|
||||
const DraggableLabel = SortableHandle((props) => {
|
||||
return <div className="maputnik-layer-list-item-handle">
|
||||
<LayerIcon
|
||||
className="layer-handle__icon"
|
||||
type={props.layerType}
|
||||
/>
|
||||
}
|
||||
}
|
||||
<span className="maputnik-layer-list-item-id">{props.layerId}</span>
|
||||
</div>
|
||||
});
|
||||
|
||||
class IconAction extends React.Component {
|
||||
static propTypes = {
|
||||
action: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
wdKey: PropTypes.string,
|
||||
classBlockName: PropTypes.string,
|
||||
classBlockModifier: PropTypes.string,
|
||||
}
|
||||
|
||||
renderIcon() {
|
||||
switch(this.props.action) {
|
||||
case 'copy': return <CopyIcon />
|
||||
case 'show': return <VisibilityIcon />
|
||||
case 'hide': return <VisibilityOffIcon />
|
||||
case 'delete': return <DeleteIcon />
|
||||
default: return null
|
||||
case 'duplicate': return <MdContentCopy />
|
||||
case 'show': return <MdVisibility />
|
||||
case 'hide': return <MdVisibilityOff />
|
||||
case 'delete': return <MdDelete />
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <a
|
||||
className="maputnik-layer-list-icon-action"
|
||||
const {classBlockName, classBlockModifier} = this.props;
|
||||
|
||||
let classAdditions = '';
|
||||
if (classBlockName) {
|
||||
classAdditions = `maputnik-layer-list-icon-action__${classBlockName}`;
|
||||
|
||||
if (classBlockModifier) {
|
||||
classAdditions += ` maputnik-layer-list-icon-action__${classBlockName}--${classBlockModifier}`;
|
||||
}
|
||||
}
|
||||
|
||||
return <button
|
||||
tabIndex="-1"
|
||||
title={this.props.action}
|
||||
className={`maputnik-layer-list-icon-action ${classAdditions}`}
|
||||
data-wd-key={this.props.wdKey}
|
||||
onClick={this.props.onClick}
|
||||
>
|
||||
{this.renderIcon()}
|
||||
</a>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
@SortableElement
|
||||
class LayerListItem extends React.Component {
|
||||
static propTypes = {
|
||||
layerIndex: PropTypes.number.isRequired,
|
||||
layerId: PropTypes.string.isRequired,
|
||||
layerType: PropTypes.string.isRequired,
|
||||
isSelected: PropTypes.bool,
|
||||
@@ -89,31 +94,42 @@ class LayerListItem extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const visibilityAction = this.props.visibility === 'visible' ? 'show' : 'hide';
|
||||
|
||||
return <li
|
||||
key={this.props.layerId}
|
||||
onClick={e => this.props.onLayerSelect(this.props.layerId)}
|
||||
onClick={e => this.props.onLayerSelect(this.props.layerIndex)}
|
||||
data-wd-key={"layer-list-item:"+this.props.layerId}
|
||||
className={classnames({
|
||||
"maputnik-layer-list-item": true,
|
||||
"maputnik-layer-list-item-selected": this.props.isSelected,
|
||||
[this.props.className]: true,
|
||||
})}>
|
||||
<LayerTypeDragHandle type={this.props.layerType} />
|
||||
<span className="maputnik-layer-list-item-id">{this.props.layerId}</span>
|
||||
<DraggableLabel {...this.props} />
|
||||
<span style={{flexGrow: 1}} />
|
||||
<IconAction
|
||||
wdKey={"layer-list-item:"+this.props.layerId+":delete"}
|
||||
action={'delete'}
|
||||
onClick={e => this.props.onLayerDestroy(this.props.layerId)}
|
||||
classBlockName="delete"
|
||||
onClick={e => this.props.onLayerDestroy(this.props.layerIndex)}
|
||||
/>
|
||||
<IconAction
|
||||
action={'copy'}
|
||||
onClick={e => this.props.onLayerCopy(this.props.layerId)}
|
||||
wdKey={"layer-list-item:"+this.props.layerId+":copy"}
|
||||
action={'duplicate'}
|
||||
classBlockName="duplicate"
|
||||
onClick={e => this.props.onLayerCopy(this.props.layerIndex)}
|
||||
/>
|
||||
<IconAction
|
||||
action={this.props.visibility === 'visible' ? 'hide' : 'show'}
|
||||
onClick={e => this.props.onLayerVisibilityToggle(this.props.layerId)}
|
||||
wdKey={"layer-list-item:"+this.props.layerId+":toggle-visibility"}
|
||||
action={visibilityAction}
|
||||
classBlockName="visibility"
|
||||
classBlockModifier={visibilityAction}
|
||||
onClick={e => this.props.onLayerVisibilityToggle(this.props.layerIndex)}
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
export default LayerListItem;
|
||||
const LayerListItemSortable = SortableElement((props) => <LayerListItem {...props} />);
|
||||
|
||||
export default LayerListItemSortable;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import styleSpec from '@mapbox/mapbox-gl-style-spec/style-spec'
|
||||
import {latest} from '@mapbox/mapbox-gl-style-spec'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import StringInput from '../inputs/StringInput'
|
||||
import SelectInput from '../inputs/SelectInput'
|
||||
import AutocompleteInput from '../inputs/AutocompleteInput'
|
||||
|
||||
class LayerSourceBlock extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
wdKey: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
sourceIds: PropTypes.array,
|
||||
}
|
||||
@@ -20,7 +19,9 @@ class LayerSourceBlock extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
return <InputBlock label={"Source"} doc={styleSpec.latest.layer.source.doc}>
|
||||
return <InputBlock label={"Source"} fieldSpec={latest.layer.source}
|
||||
data-wd-key={this.props.wdKey}
|
||||
>
|
||||
<AutocompleteInput
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import styleSpec from '@mapbox/mapbox-gl-style-spec/style-spec'
|
||||
import {latest} from '@mapbox/mapbox-gl-style-spec'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import StringInput from '../inputs/StringInput'
|
||||
import SelectInput from '../inputs/SelectInput'
|
||||
import AutocompleteInput from '../inputs/AutocompleteInput'
|
||||
|
||||
class LayerSourceLayer extends React.Component {
|
||||
@@ -22,7 +20,9 @@ class LayerSourceLayer extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
return <InputBlock label={"Source Layer"} doc={styleSpec.latest.layer['source-layer'].doc}>
|
||||
return <InputBlock label={"Source Layer"} fieldSpec={latest.layer['source-layer']}
|
||||
data-wd-key="layer-source-layer"
|
||||
>
|
||||
<AutocompleteInput
|
||||
keepMenuWithinWindowBounds={!!this.props.isFixed}
|
||||
value={this.props.value}
|
||||
|
||||
@@ -1,31 +1,52 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import styleSpec from '@mapbox/mapbox-gl-style-spec/style-spec'
|
||||
import {latest} from '@mapbox/mapbox-gl-style-spec'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import SelectInput from '../inputs/SelectInput'
|
||||
import StringInput from '../inputs/StringInput'
|
||||
|
||||
class LayerTypeBlock extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
wdKey: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
error: PropTypes.object,
|
||||
disabled: PropTypes.bool,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <InputBlock label={"Type"} doc={styleSpec.latest.layer.type.doc}>
|
||||
<SelectInput
|
||||
options={[
|
||||
['background', 'Background'],
|
||||
['fill', 'Fill'],
|
||||
['line', 'Line'],
|
||||
['symbol', 'Symbol'],
|
||||
['raster', 'Raster'],
|
||||
['circle', 'Circle'],
|
||||
['fill-extrusion', 'Fill Extrusion'],
|
||||
]}
|
||||
onChange={this.props.onChange}
|
||||
value={this.props.value}
|
||||
/>
|
||||
return <InputBlock label={"Type"} fieldSpec={latest.layer.type}
|
||||
data-wd-key={this.props.wdKey}
|
||||
error={this.props.error}
|
||||
>
|
||||
{this.props.disabled &&
|
||||
<StringInput
|
||||
value={this.props.value}
|
||||
disabled={true}
|
||||
/>
|
||||
}
|
||||
{!this.props.disabled &&
|
||||
<SelectInput
|
||||
options={[
|
||||
['background', 'Background'],
|
||||
['fill', 'Fill'],
|
||||
['line', 'Line'],
|
||||
['symbol', 'Symbol'],
|
||||
['raster', 'Raster'],
|
||||
['circle', 'Circle'],
|
||||
['fill-extrusion', 'Fill Extrusion'],
|
||||
['hillshade', 'Hillshade'],
|
||||
['heatmap', 'Heatmap'],
|
||||
]}
|
||||
onChange={this.props.onChange}
|
||||
value={this.props.value}
|
||||
/>
|
||||
}
|
||||
</InputBlock>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import styleSpec from '@mapbox/mapbox-gl-style-spec/style-spec'
|
||||
import {latest} from '@mapbox/mapbox-gl-style-spec'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import NumberInput from '../inputs/NumberInput'
|
||||
|
||||
@@ -9,16 +9,21 @@ class MaxZoomBlock extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.number,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
error: PropTypes.object,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <InputBlock label={"Max Zoom"} doc={styleSpec.latest.layer.maxzoom.doc}>
|
||||
return <InputBlock label={"Max Zoom"} fieldSpec={latest.layer.maxzoom}
|
||||
error={this.props.error}
|
||||
data-wd-key="max-zoom"
|
||||
>
|
||||
<NumberInput
|
||||
allowRange={true}
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
min={styleSpec.latest.layer.maxzoom.minimum}
|
||||
max={styleSpec.latest.layer.maxzoom.maximum}
|
||||
default={styleSpec.latest.layer.maxzoom.maximum}
|
||||
min={latest.layer.maxzoom.minimum}
|
||||
max={latest.layer.maxzoom.maximum}
|
||||
default={latest.layer.maxzoom.maximum}
|
||||
/>
|
||||
</InputBlock>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import styleSpec from '@mapbox/mapbox-gl-style-spec/style-spec'
|
||||
import {latest} from '@mapbox/mapbox-gl-style-spec'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import NumberInput from '../inputs/NumberInput'
|
||||
|
||||
@@ -9,16 +9,21 @@ class MinZoomBlock extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.number,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
error: PropTypes.object,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <InputBlock label={"Min Zoom"} doc={styleSpec.latest.layer.minzoom.doc}>
|
||||
return <InputBlock label={"Min Zoom"} fieldSpec={latest.layer.minzoom}
|
||||
error={this.props.error}
|
||||
data-wd-key="min-zoom"
|
||||
>
|
||||
<NumberInput
|
||||
allowRange={true}
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
min={styleSpec.latest.layer.minzoom.minimum}
|
||||
max={styleSpec.latest.layer.minzoom.maximum}
|
||||
default={styleSpec.latest.layer.minzoom.minimum}
|
||||
min={latest.layer.minzoom.minimum}
|
||||
max={latest.layer.minzoom.maximum}
|
||||
default={latest.layer.minzoom.minimum}
|
||||
/>
|
||||
</InputBlock>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import StringInput from '../inputs/StringInput'
|
||||
import LayerIcon from '../icons/LayerIcon'
|
||||
import {latest, expression, function as styleFunction} from '@mapbox/mapbox-gl-style-spec'
|
||||
|
||||
function groupFeaturesBySourceLayer(features) {
|
||||
const sources = {}
|
||||
@@ -30,7 +29,49 @@ function groupFeaturesBySourceLayer(features) {
|
||||
class FeatureLayerPopup extends React.Component {
|
||||
static propTypes = {
|
||||
onLayerSelect: PropTypes.func.isRequired,
|
||||
features: PropTypes.array
|
||||
features: PropTypes.array,
|
||||
zoom: PropTypes.number,
|
||||
}
|
||||
|
||||
_getFeatureColor(feature, zoom) {
|
||||
// Guard because openlayers won't have this
|
||||
if (!feature.layer.paint) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const paintProps = feature.layer.paint;
|
||||
let propName;
|
||||
|
||||
if(paintProps.hasOwnProperty("text-color") && paintProps["text-color"]) {
|
||||
propName = "text-color";
|
||||
}
|
||||
else if (paintProps.hasOwnProperty("fill-color") && paintProps["fill-color"]) {
|
||||
propName = "fill-color";
|
||||
}
|
||||
else if (paintProps.hasOwnProperty("line-color") && paintProps["line-color"]) {
|
||||
propName = "line-color";
|
||||
}
|
||||
else if (paintProps.hasOwnProperty("fill-extrusion-color") && paintProps["fill-extrusion-color"]) {
|
||||
propName = "fill-extrusion-color";
|
||||
}
|
||||
|
||||
if(propName) {
|
||||
const propertySpec = latest["paint_"+feature.layer.type][propName];
|
||||
let color = feature.layer.paint[propName];
|
||||
return String(color);
|
||||
}
|
||||
else {
|
||||
// Default color
|
||||
return "black";
|
||||
}
|
||||
}
|
||||
// This is quite complex, just incase there's an edgecase we're missing
|
||||
// always return black if we get an unexpected error.
|
||||
catch (err) {
|
||||
console.warn("Unable to get feature color, error:", err);
|
||||
return "black";
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -38,21 +79,33 @@ class FeatureLayerPopup extends React.Component {
|
||||
|
||||
const items = Object.keys(sources).map(vectorLayerId => {
|
||||
const layers = sources[vectorLayerId].map((feature, idx) => {
|
||||
return <label
|
||||
key={idx}
|
||||
className="maputnik-popup-layer"
|
||||
onClick={() => {
|
||||
this.props.onLayerSelect(feature.layer.id)
|
||||
}}
|
||||
const featureColor = this._getFeatureColor(feature, this.props.zoom);
|
||||
|
||||
return <div
|
||||
key={idx}
|
||||
className="maputnik-popup-layer"
|
||||
>
|
||||
<LayerIcon type={feature.layer.type} style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
paddingRight: 3
|
||||
}}/>
|
||||
{feature.layer.id}
|
||||
{feature.counter && <span> × {feature.counter}</span>}
|
||||
</label>
|
||||
<div
|
||||
className="maputnik-popup-layer__swatch"
|
||||
style={{background: featureColor}}
|
||||
></div>
|
||||
<label
|
||||
className="maputnik-popup-layer__label"
|
||||
onClick={() => {
|
||||
this.props.onLayerSelect(feature.layer.id)
|
||||
}}
|
||||
>
|
||||
{feature.layer.type &&
|
||||
<LayerIcon type={feature.layer.type} style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
paddingRight: 3
|
||||
}}/>
|
||||
}
|
||||
{feature.layer.id}
|
||||
{feature.counter && <span> × {feature.counter}</span>}
|
||||
</label>
|
||||
</div>
|
||||
})
|
||||
return <div key={vectorLayerId}>
|
||||
<div className="maputnik-popup-layer-id">{vectorLayerId}</div>
|
||||
|
||||
@@ -21,8 +21,8 @@ function renderProperties(feature) {
|
||||
})
|
||||
}
|
||||
|
||||
function renderFeature(feature) {
|
||||
return <div key={feature.id}>
|
||||
function renderFeature(feature, idx) {
|
||||
return <div key={`${feature.sourceLayer}-${idx}`}>
|
||||
<div className="maputnik-popup-layer-id">{feature.layer['source-layer']}{feature.inspectModeCounter && <span> × {feature.inspectModeCounter}</span>}</div>
|
||||
<InputBlock key={"property-type"} label={"$type"}>
|
||||
<StringInput value={feature.geometry.type} style={{backgroundColor: 'transparent'}} />
|
||||
@@ -43,7 +43,7 @@ function removeDuplicatedFeatures(features) {
|
||||
if(featureIndex === -1) {
|
||||
uniqueFeatures.push(feature)
|
||||
} else {
|
||||
if(uniqueFeatures[featureIndex].hasOwnProperty('counter')) {
|
||||
if(uniqueFeatures[featureIndex].hasOwnProperty('inspectModeCounter')) {
|
||||
uniqueFeatures[featureIndex].inspectModeCounter++
|
||||
} else {
|
||||
uniqueFeatures[featureIndex].inspectModeCounter = 2
|
||||
|
||||
@@ -5,7 +5,6 @@ import MapboxGl from 'mapbox-gl'
|
||||
import MapboxInspect from 'mapbox-gl-inspect'
|
||||
import FeatureLayerPopup from './FeatureLayerPopup'
|
||||
import FeaturePropertyPopup from './FeaturePropertyPopup'
|
||||
import style from '../../libs/style.js'
|
||||
import tokens from '../../config/tokens.json'
|
||||
import colors from 'mapbox-gl-inspect/lib/colors'
|
||||
import Color from 'color'
|
||||
@@ -15,10 +14,12 @@ import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import '../../mapboxgl.css'
|
||||
import '../../libs/mapbox-rtl'
|
||||
|
||||
function renderPropertyPopup(features) {
|
||||
var mountNode = document.createElement('div');
|
||||
ReactDOM.render(<FeaturePropertyPopup features={features} />, mountNode)
|
||||
return mountNode.innerHTML;
|
||||
|
||||
const IS_SUPPORTED = MapboxGl.supported();
|
||||
|
||||
function renderPopup(popup, mountNode) {
|
||||
ReactDOM.render(popup, mountNode);
|
||||
return mountNode;
|
||||
}
|
||||
|
||||
function buildInspectStyle(originalMapStyle, coloredLayers, highlightedLayer) {
|
||||
@@ -38,7 +39,7 @@ function buildInspectStyle(originalMapStyle, coloredLayers, highlightedLayer) {
|
||||
const sources = {}
|
||||
Object.keys(originalMapStyle.sources).forEach(sourceId => {
|
||||
const source = originalMapStyle.sources[sourceId]
|
||||
if(source.type !== 'raster') {
|
||||
if(source.type !== 'raster' && source.type !== 'raster-dem') {
|
||||
sources[sourceId] = source
|
||||
}
|
||||
})
|
||||
@@ -58,13 +59,18 @@ export default class MapboxGlMap extends React.Component {
|
||||
mapStyle: PropTypes.object.isRequired,
|
||||
inspectModeEnabled: PropTypes.bool.isRequired,
|
||||
highlightedLayer: PropTypes.object,
|
||||
options: PropTypes.object,
|
||||
replaceAccessTokens: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onMapLoaded: () => {},
|
||||
onDataChange: () => {},
|
||||
onLayerSelect: () => {},
|
||||
onChange: () => {},
|
||||
mapboxAccessToken: tokens.mapbox,
|
||||
options: {},
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
@@ -73,46 +79,80 @@ export default class MapboxGlMap extends React.Component {
|
||||
this.state = {
|
||||
map: null,
|
||||
inspect: null,
|
||||
isPopupOpen: false,
|
||||
popupX: 0,
|
||||
popupY: 0,
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
updateMapFromProps(props) {
|
||||
if(!IS_SUPPORTED) return;
|
||||
|
||||
if(!this.state.map) return
|
||||
const metadata = nextProps.mapStyle.metadata || {}
|
||||
const metadata = props.mapStyle.metadata || {}
|
||||
MapboxGl.accessToken = metadata['maputnik:mapbox_access_token'] || tokens.mapbox
|
||||
|
||||
if(!nextProps.inspectModeEnabled) {
|
||||
//Mapbox GL now does diffing natively so we don't need to calculate
|
||||
//the necessary operations ourselves!
|
||||
this.state.map.setStyle(nextProps.mapStyle, { diff: true})
|
||||
}
|
||||
//Mapbox GL now does diffing natively so we don't need to calculate
|
||||
//the necessary operations ourselves!
|
||||
this.state.map.setStyle(
|
||||
this.props.replaceAccessTokens(props.mapStyle),
|
||||
{diff: true}
|
||||
)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if(!IS_SUPPORTED) return;
|
||||
|
||||
const map = this.state.map;
|
||||
|
||||
this.updateMapFromProps(this.props);
|
||||
|
||||
if(this.props.inspectModeEnabled !== prevProps.inspectModeEnabled) {
|
||||
// HACK: Fix for <https://github.com/maputnik/editor/issues/576>, while we wait for a proper fix.
|
||||
// eslint-disable-next-line
|
||||
this.state.inspect._popupBlocked = false;
|
||||
this.state.inspect.toggleInspector()
|
||||
}
|
||||
if(this.props.inspectModeEnabled) {
|
||||
this.state.inspect.render()
|
||||
}
|
||||
|
||||
if (map) {
|
||||
map.showTileBoundaries = this.props.options.showTileBoundaries;
|
||||
map.showCollisionBoxes = this.props.options.showCollisionBoxes;
|
||||
map.showOverdrawInspector = this.props.options.showOverdrawInspector;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const map = new MapboxGl.Map({
|
||||
if(!IS_SUPPORTED) return;
|
||||
|
||||
const mapOpts = {
|
||||
...this.props.options,
|
||||
container: this.container,
|
||||
style: this.props.mapStyle,
|
||||
hash: true,
|
||||
})
|
||||
maxZoom: 24
|
||||
}
|
||||
|
||||
const zoom = new ZoomControl;
|
||||
map.addControl(zoom, 'top-right');
|
||||
const map = new MapboxGl.Map(mapOpts);
|
||||
|
||||
const nav = new MapboxGl.NavigationControl();
|
||||
const mapViewChange = () => {
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
this.props.onChange({center, zoom});
|
||||
}
|
||||
mapViewChange();
|
||||
|
||||
map.showTileBoundaries = mapOpts.showTileBoundaries;
|
||||
map.showCollisionBoxes = mapOpts.showCollisionBoxes;
|
||||
map.showOverdrawInspector = mapOpts.showOverdrawInspector;
|
||||
|
||||
const zoomControl = new ZoomControl;
|
||||
map.addControl(zoomControl, 'top-right');
|
||||
|
||||
const nav = new MapboxGl.NavigationControl({visualizePitch:true});
|
||||
map.addControl(nav, 'top-right');
|
||||
|
||||
const tmpNode = document.createElement('div');
|
||||
|
||||
const inspect = new MapboxInspect({
|
||||
popup: new MapboxGl.Popup({
|
||||
closeOnClick: false
|
||||
@@ -121,24 +161,30 @@ export default class MapboxGlMap extends React.Component {
|
||||
showMapPopupOnHover: false,
|
||||
showInspectMapPopupOnHover: true,
|
||||
showInspectButton: false,
|
||||
blockHoverPopupOnClick: true,
|
||||
assignLayerColor: (layerId, alpha) => {
|
||||
return Color(colors.brightColor(layerId, alpha)).desaturate(0.5).string()
|
||||
},
|
||||
buildInspectStyle: (originalMapStyle, coloredLayers) => buildInspectStyle(originalMapStyle, coloredLayers, this.props.highlightedLayer),
|
||||
renderPopup: features => {
|
||||
if(this.props.inspectModeEnabled) {
|
||||
return renderPropertyPopup(features)
|
||||
return renderPopup(<FeaturePropertyPopup features={features} />, tmpNode);
|
||||
} else {
|
||||
var mountNode = document.createElement('div');
|
||||
ReactDOM.render(<FeatureLayerPopup features={features} onLayerSelect={this.props.onLayerSelect} />, mountNode)
|
||||
return mountNode
|
||||
return renderPopup(<FeatureLayerPopup features={features} onLayerSelect={this.onLayerSelectById} zoom={this.state.zoom} />, tmpNode);
|
||||
}
|
||||
}
|
||||
})
|
||||
map.addControl(inspect)
|
||||
|
||||
map.on("style.load", () => {
|
||||
this.setState({ map, inspect });
|
||||
this.setState({
|
||||
map,
|
||||
inspect,
|
||||
zoom: map.getZoom()
|
||||
});
|
||||
if(this.props.inspectModeEnabled) {
|
||||
inspect.toggleInspector();
|
||||
}
|
||||
})
|
||||
|
||||
map.on("data", e => {
|
||||
@@ -147,12 +193,41 @@ export default class MapboxGlMap extends React.Component {
|
||||
map: this.state.map
|
||||
})
|
||||
})
|
||||
|
||||
map.on("error", e => {
|
||||
console.log("ERROR", e);
|
||||
})
|
||||
|
||||
map.on("zoom", e => {
|
||||
this.setState({
|
||||
zoom: map.getZoom()
|
||||
});
|
||||
});
|
||||
|
||||
map.on("dragend", mapViewChange);
|
||||
map.on("zoomend", mapViewChange);
|
||||
}
|
||||
|
||||
onLayerSelectById = (id) => {
|
||||
const index = this.props.mapStyle.layers.findIndex(layer => layer.id === id);
|
||||
this.props.onLayerSelect(index);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div
|
||||
className="maputnik-map"
|
||||
ref={x => this.container = x}
|
||||
></div>
|
||||
if(IS_SUPPORTED) {
|
||||
return <div
|
||||
className="maputnik-map__map"
|
||||
ref={x => this.container = x}
|
||||
></div>
|
||||
}
|
||||
else {
|
||||
return <div
|
||||
className="maputnik-map maputnik-map--error"
|
||||
>
|
||||
<div className="maputnik-map__error-message">
|
||||
Error: Cannot load MapboxGL, WebGL is either unsupported or disabled
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import style from '../../libs/style.js'
|
||||
import isEqual from 'lodash.isequal'
|
||||
import { loadJSON } from '../../libs/urlopen'
|
||||
import 'openlayers/dist/ol.css'
|
||||
|
||||
function suitableVectorSource(mapStyle) {
|
||||
const sources = Object.keys(mapStyle.sources)
|
||||
.map(sourceId => {
|
||||
return {
|
||||
id: sourceId,
|
||||
source: mapStyle.sources[sourceId]
|
||||
}
|
||||
})
|
||||
.filter(({source}) => (source.type === 'vector' || source.type === 'geojson'))
|
||||
return sources[0]
|
||||
}
|
||||
|
||||
function toVectorLayer(source, tilegrid, cb) {
|
||||
function newMVTLayer(tileUrl) {
|
||||
const ol = require('openlayers')
|
||||
return new ol.layer.VectorTile({
|
||||
source: new ol.source.VectorTile({
|
||||
format: new ol.format.MVT(),
|
||||
tileGrid: tilegrid,
|
||||
tilePixelRatio: 8,
|
||||
url: tileUrl
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function newGeoJSONLayer(sourceUrl) {
|
||||
const ol = require('openlayers')
|
||||
return new ol.layer.Vector({
|
||||
source: new ol.source.Vector({
|
||||
format: new ol.format.GeoJSON(),
|
||||
url: sourceUrl
|
||||
})
|
||||
})
|
||||
}
|
||||
if (source.type === 'vector') {
|
||||
if(!source.tiles) {
|
||||
sourceFromTileJSON(source.url, tileSource => {
|
||||
cb(newMVTLayer(tileSource.tiles[0]))
|
||||
})
|
||||
} else {
|
||||
cb(newMVTLayer(source.tiles[0]))
|
||||
}
|
||||
} else if (source.type === 'geojson') {
|
||||
cb(newGeoJSONLayer(source.data))
|
||||
}
|
||||
}
|
||||
|
||||
function sourceFromTileJSON(url, cb) {
|
||||
loadJSON(url, null, tilejson => {
|
||||
if(!tilejson) return
|
||||
cb({
|
||||
type: 'vector',
|
||||
tiles: tilejson.tiles,
|
||||
minzoom: tilejson.minzoom,
|
||||
maxzoom: tilejson.maxzoom,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
class OpenLayers3Map extends React.Component {
|
||||
static propTypes = {
|
||||
onDataChange: PropTypes.func,
|
||||
mapStyle: PropTypes.object.isRequired,
|
||||
accessToken: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onMapLoaded: () => {},
|
||||
onDataChange: () => {},
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.tilegrid = null
|
||||
this.resolutions = null
|
||||
this.layer = null
|
||||
this.map = null
|
||||
}
|
||||
|
||||
updateStyle(newMapStyle) {
|
||||
const oldSource = suitableVectorSource(this.props.mapStyle)
|
||||
const newSource = suitableVectorSource(newMapStyle)
|
||||
const resolutions = this.resolutions
|
||||
|
||||
function setStyleFunc(map, layer) {
|
||||
const olms = require('ol-mapbox-style')
|
||||
const styleFunc = olms.getStyleFunction(newMapStyle, newSource.id, resolutions)
|
||||
layer.setStyle(styleFunc)
|
||||
//NOTE: We need to mark the source as changed in order
|
||||
//to trigger a rerender
|
||||
layer.getSource().changed()
|
||||
map.render()
|
||||
}
|
||||
|
||||
if(newSource) {
|
||||
if(this.layer && !isEqual(oldSource, newSource)) {
|
||||
this.map.removeLayer(this.layer)
|
||||
this.layer = null
|
||||
}
|
||||
|
||||
if(!this.layer) {
|
||||
var self = this
|
||||
toVectorLayer(newSource.source, this.tilegrid, vectorLayer => {
|
||||
self.layer = vectorLayer
|
||||
self.map.addLayer(self.layer)
|
||||
setStyleFunc(self.map, self.layer)
|
||||
})
|
||||
} else {
|
||||
setStyleFunc(this.map, this.layer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
require.ensure(["openlayers", "ol-mapbox-style"], () => {
|
||||
if(!this.map || !this.resolutions) return
|
||||
this.updateStyle(nextProps.mapStyle)
|
||||
})
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
//Load OpenLayers dynamically once we need it
|
||||
//TODO: Make this more convenient
|
||||
require.ensure(["openlayers", "ol-mapbox-style"], ()=> {
|
||||
console.log('Loaded OpenLayers3 renderer')
|
||||
|
||||
const ol = require('openlayers')
|
||||
const olms = require('ol-mapbox-style')
|
||||
|
||||
this.tilegrid = ol.tilegrid.createXYZ({tileSize: 512, maxZoom: 22})
|
||||
this.resolutions = this.tilegrid.getResolutions()
|
||||
|
||||
const map = new ol.Map({
|
||||
target: this.container,
|
||||
layers: [],
|
||||
view: new ol.View({
|
||||
zoom: 2,
|
||||
center: [52.5, -78.4]
|
||||
})
|
||||
})
|
||||
map.addControl(new ol.control.Zoom())
|
||||
this.map = map
|
||||
this.updateStyle(this.props.mapStyle)
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div
|
||||
ref={x => this.container = x}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 40,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 'calc(100% - 40px)',
|
||||
width: "75%",
|
||||
backgroundColor: '#fff',
|
||||
...this.props.style,
|
||||
}}>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenLayers3Map
|
||||
189
src/components/map/OpenLayersMap.jsx
Normal file
189
src/components/map/OpenLayersMap.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React from 'react'
|
||||
import {throttle} from 'lodash';
|
||||
import PropTypes from 'prop-types'
|
||||
import { loadJSON } from '../../libs/urlopen'
|
||||
|
||||
import FeatureLayerPopup from './FeatureLayerPopup';
|
||||
|
||||
import 'ol/ol.css'
|
||||
import {apply} from 'ol-mapbox-style';
|
||||
import {Map, View, Proj, Overlay} from 'ol';
|
||||
|
||||
import {toLonLat} from 'ol/proj';
|
||||
import {toStringHDMS} from 'ol/coordinate';
|
||||
|
||||
|
||||
function renderCoords (coords) {
|
||||
if (!coords || coords.length < 2) {
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
return <span className="maputnik-coords">
|
||||
{coords.map((coord) => String(coord).padStart(7, "\u00A0")).join(', ')}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
export default class OpenLayersMap extends React.Component {
|
||||
static propTypes = {
|
||||
onDataChange: PropTypes.func,
|
||||
mapStyle: PropTypes.object.isRequired,
|
||||
accessToken: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
onLayerSelect: PropTypes.func.isRequired,
|
||||
debugToolbox: PropTypes.bool.isRequired,
|
||||
replaceAccessTokens: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onMapLoaded: () => {},
|
||||
onDataChange: () => {},
|
||||
onLayerSelect: () => {},
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
zoom: 0,
|
||||
rotation: 0,
|
||||
cursor: [],
|
||||
center: [],
|
||||
};
|
||||
this.updateStyle = throttle(this._updateStyle.bind(this), 200);
|
||||
}
|
||||
|
||||
_updateStyle(newMapStyle) {
|
||||
if(!this.map) return;
|
||||
|
||||
// See <https://github.com/openlayers/ol-mapbox-style/issues/215#issuecomment-493198815>
|
||||
this.map.getLayers().clear();
|
||||
apply(this.map, newMapStyle);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.mapStyle !== prevProps.mapStyle) {
|
||||
this.updateStyle(
|
||||
this.props.replaceAccessTokens(this.props.mapStyle)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.overlay = new Overlay({
|
||||
element: this.popupContainer,
|
||||
autoPan: true,
|
||||
autoPanAnimation: {
|
||||
duration: 250
|
||||
}
|
||||
});
|
||||
|
||||
const map = new Map({
|
||||
target: this.container,
|
||||
overlays: [this.overlay],
|
||||
view: new View({
|
||||
zoom: 1,
|
||||
center: [180, -90],
|
||||
})
|
||||
});
|
||||
|
||||
map.on('pointermove', (evt) => {
|
||||
var coords = toLonLat(evt.coordinate);
|
||||
this.setState({
|
||||
cursor: [
|
||||
coords[0].toFixed(2),
|
||||
coords[1].toFixed(2)
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
const onMoveEnd = () => {
|
||||
const zoom = map.getView().getZoom();
|
||||
const center = toLonLat(map.getView().getCenter());
|
||||
|
||||
this.props.onChange({
|
||||
zoom,
|
||||
center: {
|
||||
lng: center[0],
|
||||
lat: center[1],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMoveEnd();
|
||||
map.on('moveend', onMoveEnd);
|
||||
|
||||
map.on('postrender', (evt) => {
|
||||
const center = toLonLat(map.getView().getCenter());
|
||||
this.setState({
|
||||
center: [
|
||||
center[0].toFixed(2),
|
||||
center[1].toFixed(2),
|
||||
],
|
||||
rotation: map.getView().getRotation().toFixed(2),
|
||||
zoom: map.getView().getZoom().toFixed(2)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
this.map = map;
|
||||
this.updateStyle(
|
||||
this.props.replaceAccessTokens(this.props.mapStyle)
|
||||
);
|
||||
}
|
||||
|
||||
closeOverlay = (e) => {
|
||||
e.target.blur();
|
||||
this.overlay.setPosition(undefined);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className="maputnik-ol-container">
|
||||
<div
|
||||
ref={x => this.popupContainer = x}
|
||||
style={{background: "black"}}
|
||||
className="maputnik-popup"
|
||||
>
|
||||
<button
|
||||
className="mapboxgl-popup-close-button"
|
||||
onClick={this.closeOverlay}
|
||||
aria-label="Close popup"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<FeatureLayerPopup
|
||||
features={this.state.selectedFeatures || []}
|
||||
onLayerSelect={this.props.onLayerSelect}
|
||||
/>
|
||||
</div>
|
||||
<div className="maputnik-ol-zoom">
|
||||
Zoom: {this.state.zoom}
|
||||
</div>
|
||||
{this.props.debugToolbox &&
|
||||
<div className="maputnik-ol-debug">
|
||||
<div>
|
||||
<label>cursor: </label>
|
||||
<span>{renderCoords(this.state.cursor)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>center: </label>
|
||||
<span>{renderCoords(this.state.center)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>rotation: </label>
|
||||
<span>{this.state.rotation}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
className="maputnik-ol"
|
||||
ref={x => this.container = x}
|
||||
style={{
|
||||
...this.props.style,
|
||||
}}>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@ import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import Button from '../Button'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import StringInput from '../inputs/StringInput'
|
||||
import SelectInput from '../inputs/SelectInput'
|
||||
import Modal from './Modal'
|
||||
|
||||
import LayerTypeBlock from '../layers/LayerTypeBlock'
|
||||
@@ -23,7 +20,7 @@ class AddModal extends React.Component {
|
||||
sources: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
addLayer() {
|
||||
addLayer = () => {
|
||||
const changedLayers = this.props.layers.slice(0)
|
||||
const layer = {
|
||||
id: this.state.id,
|
||||
@@ -56,10 +53,10 @@ class AddModal extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUpdate(nextProps, nextState) {
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
// Check if source is valid for new type
|
||||
const oldType = this.state.type;
|
||||
const newType = nextState.type;
|
||||
const oldType = prevState.type;
|
||||
const newType = this.state.type;
|
||||
|
||||
const availableSourcesOld = this.getSources(oldType);
|
||||
const availableSourcesNew = this.getSources(newType);
|
||||
@@ -67,11 +64,11 @@ class AddModal extends React.Component {
|
||||
if(
|
||||
// Type has changed
|
||||
oldType !== newType
|
||||
&& this.state.source !== ""
|
||||
&& prevState.source !== ""
|
||||
// Was a valid source previously
|
||||
&& availableSourcesOld.indexOf(this.state.source) > -1
|
||||
&& availableSourcesOld.indexOf(prevState.source) > -1
|
||||
// And is not a valid source now
|
||||
&& availableSourcesNew.indexOf(nextState.source) < 0
|
||||
&& availableSourcesNew.indexOf(this.state.source) < 0
|
||||
) {
|
||||
// Clear the source
|
||||
this.setState({
|
||||
@@ -94,15 +91,24 @@ class AddModal extends React.Component {
|
||||
"line",
|
||||
"symbol",
|
||||
"circle",
|
||||
"fill-extrusion"
|
||||
"fill-extrusion",
|
||||
"heatmap"
|
||||
],
|
||||
raster: [
|
||||
"raster"
|
||||
],
|
||||
geojson: [
|
||||
"fill",
|
||||
"line",
|
||||
"symbol",
|
||||
"circle",
|
||||
"fill-extrusion",
|
||||
"heatmap"
|
||||
]
|
||||
}
|
||||
|
||||
for(let [key, val] of Object.entries(this.props.sources)) {
|
||||
if(types[val.type].indexOf(type) > -1) {
|
||||
if(types[val.type] && types[val.type].indexOf(type) > -1) {
|
||||
sources.push(key);
|
||||
}
|
||||
}
|
||||
@@ -119,24 +125,31 @@ class AddModal extends React.Component {
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={this.props.onOpenToggle}
|
||||
title={'Add Layer'}
|
||||
data-wd-key="modal:add-layer"
|
||||
className="maputnik-add-modal"
|
||||
>
|
||||
<div className="maputnik-add-layer">
|
||||
<LayerIdBlock
|
||||
value={this.state.id}
|
||||
onChange={v => this.setState({ id: v })}
|
||||
wdKey="add-layer.layer-id"
|
||||
onChange={v => {
|
||||
this.setState({ id: v })
|
||||
}}
|
||||
/>
|
||||
<LayerTypeBlock
|
||||
value={this.state.type}
|
||||
wdKey="add-layer.layer-type"
|
||||
onChange={v => this.setState({ type: v })}
|
||||
/>
|
||||
{this.state.type !== 'background' &&
|
||||
<LayerSourceBlock
|
||||
sourceIds={sources}
|
||||
wdKey="add-layer.layer-source-block"
|
||||
value={this.state.source}
|
||||
onChange={v => this.setState({ source: v })}
|
||||
/>
|
||||
}
|
||||
{this.state.type !== 'background' && this.state.type !== 'raster' &&
|
||||
{['background', 'raster', 'hillshade', 'heatmap'].indexOf(this.state.type) < 0 &&
|
||||
<LayerSourceLayerBlock
|
||||
isFixed={true}
|
||||
sourceLayerIds={layers}
|
||||
@@ -144,7 +157,11 @@ class AddModal extends React.Component {
|
||||
onChange={v => this.setState({ 'source-layer': v })}
|
||||
/>
|
||||
}
|
||||
<Button className="maputnik-add-layer-button" onClick={this.addLayer.bind(this)}>
|
||||
<Button
|
||||
className="maputnik-add-layer-button"
|
||||
onClick={this.addLayer}
|
||||
data-wd-key="add-layer"
|
||||
>
|
||||
Add Layer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
73
src/components/modals/DebugModal.js
Normal file
73
src/components/modals/DebugModal.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import Modal from './Modal'
|
||||
|
||||
|
||||
class DebugModal extends React.Component {
|
||||
static propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
renderer: PropTypes.string.isRequired,
|
||||
onChangeMaboxGlDebug: PropTypes.func.isRequired,
|
||||
onChangeOpenlayersDebug: PropTypes.func.isRequired,
|
||||
onOpenToggle: PropTypes.func.isRequired,
|
||||
mapboxGlDebugOptions: PropTypes.object,
|
||||
openlayersDebugOptions: PropTypes.object,
|
||||
mapView: PropTypes.object,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {mapView} = this.props;
|
||||
|
||||
const osmZoom = Math.round(mapView.zoom)+1;
|
||||
const osmLon = Number.parseFloat(mapView.center.lng).toFixed(5);
|
||||
const osmLat = Number.parseFloat(mapView.center.lat).toFixed(5);
|
||||
|
||||
return <Modal
|
||||
data-wd-key="debug-modal"
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={this.props.onOpenToggle}
|
||||
title={'Debug'}
|
||||
>
|
||||
<div className="maputnik-modal-section maputnik-modal-shortcuts">
|
||||
<h4>Options</h4>
|
||||
{this.props.renderer === 'mbgljs' &&
|
||||
<ul>
|
||||
{Object.entries(this.props.mapboxGlDebugOptions).map(([key, val]) => {
|
||||
return <li key={key}>
|
||||
<label>
|
||||
<input type="checkbox" checked={val} onClick={(e) => this.props.onChangeMaboxGlDebug(key, e.target.checked)} /> {key}
|
||||
</label>
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
{this.props.renderer === 'ol' &&
|
||||
<ul>
|
||||
{Object.entries(this.props.openlayersDebugOptions).map(([key, val]) => {
|
||||
return <li key={key}>
|
||||
<label>
|
||||
<input type="checkbox" checked={val} onClick={(e) => this.props.onChangeOpenlayersDebug(key, e.target.checked)} /> {key}
|
||||
</label>
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
<div className="maputnik-modal-section">
|
||||
<h4>Links</h4>
|
||||
<p>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`https://www.openstreetmap.org/#map=${osmZoom}/${osmLat}/${osmLon}`}
|
||||
>
|
||||
Open in OSM
|
||||
</a> — Opens the current view on openstreetmap.org
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
}
|
||||
|
||||
export default DebugModal;
|
||||
@@ -1,190 +1,19 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Slugify from 'slugify'
|
||||
import { saveAs } from 'file-saver'
|
||||
|
||||
import styleSpec from '@mapbox/mapbox-gl-style-spec/style-spec'
|
||||
import {format} from '@mapbox/mapbox-gl-style-spec'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import StringInput from '../inputs/StringInput'
|
||||
import SelectInput from '../inputs/SelectInput'
|
||||
import CheckboxInput from '../inputs/CheckboxInput'
|
||||
import Button from '../Button'
|
||||
import Modal from './Modal'
|
||||
import MdFileDownload from 'react-icons/lib/md/file-download'
|
||||
import TiClipboard from 'react-icons/lib/ti/clipboard'
|
||||
import style from '../../libs/style.js'
|
||||
import GitHub from 'github-api'
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard'
|
||||
import {MdFileDownload} from 'react-icons/md'
|
||||
import style from '../../libs/style'
|
||||
import fieldSpecAdditional from '../../libs/field-spec-additional'
|
||||
|
||||
|
||||
class Gist extends React.Component {
|
||||
static propTypes = {
|
||||
mapStyle: PropTypes.object.isRequired,
|
||||
onStyleChanged: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
preview: false,
|
||||
saving: false,
|
||||
latestGist: null,
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.setState({
|
||||
...this.state,
|
||||
preview: !!(nextProps.mapStyle.metadata || {})['maputnik:openmaptiles_access_token']
|
||||
})
|
||||
}
|
||||
|
||||
onSave() {
|
||||
this.setState({
|
||||
...this.state,
|
||||
saving: true
|
||||
});
|
||||
const preview = this.state.preview && (this.props.mapStyle.metadata || {})['maputnik:openmaptiles_access_token'];
|
||||
|
||||
const mapStyleStr = preview ?
|
||||
styleSpec.format(stripAccessTokens(style.replaceAccessToken(this.props.mapStyle))) :
|
||||
styleSpec.format(stripAccessTokens(this.props.mapStyle));
|
||||
const styleTitle = this.props.mapStyle.name || 'Style';
|
||||
const htmlStr = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>`+styleTitle+` Preview</title>
|
||||
<link rel="stylesheet" type="text/css" href="https://api.mapbox.com/mapbox-gl-js/v0.43.0/mapbox-gl.css" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v0.43.0/mapbox-gl.js"></script>
|
||||
<style>
|
||||
body { margin:0; padding:0; }
|
||||
#map { position:absolute; top:0; bottom:0; width:100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id='map'></div>
|
||||
<script>
|
||||
var map = new mapboxgl.Map({
|
||||
container: 'map',
|
||||
style: 'style.json',
|
||||
attributionControl: true,
|
||||
hash: true
|
||||
});
|
||||
map.addControl(new mapboxgl.NavigationControl());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
const files = {
|
||||
"style.json": {
|
||||
content: mapStyleStr
|
||||
}
|
||||
}
|
||||
if(preview) {
|
||||
files["index.html"] = {
|
||||
content: htmlStr
|
||||
}
|
||||
}
|
||||
const gh = new GitHub();
|
||||
let gist = gh.getGist(); // not a gist yet
|
||||
gist.create({
|
||||
public: true,
|
||||
description: styleTitle,
|
||||
files: files
|
||||
}).then(function({data}) {
|
||||
return gist.read();
|
||||
}).then(function({data}) {
|
||||
this.setState({
|
||||
...this.state,
|
||||
latestGist: data,
|
||||
saving: false,
|
||||
});
|
||||
}.bind(this));
|
||||
}
|
||||
|
||||
onPreviewChange(value) {
|
||||
this.setState({
|
||||
...this.state,
|
||||
preview: value
|
||||
})
|
||||
}
|
||||
|
||||
changeMetadataProperty(property, value) {
|
||||
const changedStyle = {
|
||||
...this.props.mapStyle,
|
||||
metadata: {
|
||||
...this.props.mapStyle.metadata,
|
||||
[property]: value
|
||||
}
|
||||
}
|
||||
this.props.onStyleChanged(changedStyle)
|
||||
}
|
||||
|
||||
renderPreviewLink() {
|
||||
const gist = this.state.latestGist;
|
||||
const user = gist.user || 'anonymous';
|
||||
const preview = !!gist.files['index.html'];
|
||||
if(preview) {
|
||||
return <span><a target="_blank" rel="noopener noreferrer" href={"https://bl.ocks.org/"+user+"/"+gist.id}>Preview</a>,{' '}</span>
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
renderLatestGist() {
|
||||
const gist = this.state.latestGist;
|
||||
const saving = this.state.saving;
|
||||
if(saving) {
|
||||
return <p>Saving...</p>
|
||||
} else if(gist) {
|
||||
const user = gist.user || 'anonymous';
|
||||
const rawGistLink = "https://gist.githubusercontent.com/" + user + "/" + gist.id + "/raw/" + gist.history[0].version + "/style.json"
|
||||
const maputnikStyleLink = "https://maputnik.github.io/editor/?style=" + rawGistLink
|
||||
return <div className="maputnik-render-gist">
|
||||
<p>
|
||||
Latest saved gist:{' '}
|
||||
{this.renderPreviewLink(this)}
|
||||
<a target="_blank" rel="noopener noreferrer" href={"https://gist.github.com/" + user + "/" + gist.id}>Source</a>
|
||||
</p>
|
||||
<p>
|
||||
<CopyToClipboard text={maputnikStyleLink}>
|
||||
<span>Share this style: <Button><TiClipboard size={18} /></Button></span>
|
||||
</CopyToClipboard>
|
||||
<StringInput value={maputnikStyleLink} />
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className="maputnik-export-gist">
|
||||
<Button onClick={this.onSave.bind(this)}>
|
||||
<MdFileDownload />
|
||||
Save to Gist (anonymous)
|
||||
</Button>
|
||||
{' '}
|
||||
<CheckboxInput
|
||||
value={this.state.preview}
|
||||
name='gist-style-preview'
|
||||
onChange={this.onPreviewChange.bind(this)}
|
||||
/>
|
||||
<span> Include preview</span>
|
||||
{this.state.preview ?
|
||||
<div>
|
||||
<InputBlock
|
||||
label={"OpenMapTiles Access Token: "}>
|
||||
<StringInput
|
||||
value={(this.props.mapStyle.metadata || {})['maputnik:openmaptiles_access_token']}
|
||||
onChange={this.changeMetadataProperty.bind(this, "maputnik:openmaptiles_access_token")}/>
|
||||
</InputBlock>
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://openmaptiles.com/hosting/">Get your free access token</a>
|
||||
</div>
|
||||
: null}
|
||||
{this.renderLatestGist()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
function stripAccessTokens(mapStyle) {
|
||||
const changedMetadata = { ...mapStyle.metadata }
|
||||
@@ -209,15 +38,40 @@ class ExportModal extends React.Component {
|
||||
}
|
||||
|
||||
downloadStyle() {
|
||||
const blob = new Blob([styleSpec.format(stripAccessTokens(this.props.mapStyle))], {type: "application/json;charset=utf-8"});
|
||||
saveAs(blob, this.props.mapStyle.id + ".json");
|
||||
const tokenStyle = format(stripAccessTokens(style.replaceAccessTokens(this.props.mapStyle)));
|
||||
|
||||
const blob = new Blob([tokenStyle], {type: "application/json;charset=utf-8"});
|
||||
let exportName;
|
||||
if(this.props.mapStyle.name) {
|
||||
exportName = Slugify(this.props.mapStyle.name, {
|
||||
replacement: '_',
|
||||
lower: true
|
||||
})
|
||||
} else {
|
||||
exportName = this.props.mapStyle.id
|
||||
}
|
||||
saveAs(blob, exportName + ".json");
|
||||
}
|
||||
|
||||
changeMetadataProperty(property, value) {
|
||||
const changedStyle = {
|
||||
...this.props.mapStyle,
|
||||
metadata: {
|
||||
...this.props.mapStyle.metadata,
|
||||
[property]: value
|
||||
}
|
||||
}
|
||||
this.props.onStyleChanged(changedStyle)
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
return <Modal
|
||||
data-wd-key="export-modal"
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={this.props.onOpenToggle}
|
||||
title={'Export Style'}
|
||||
className="maputnik-export-modal"
|
||||
>
|
||||
|
||||
<div className="maputnik-modal-section">
|
||||
@@ -225,16 +79,43 @@ class ExportModal extends React.Component {
|
||||
<p>
|
||||
Download a JSON style to your computer.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<InputBlock
|
||||
label={fieldSpecAdditional.maputnik.mapbox_access_token.label}
|
||||
fieldSpec={fieldSpecAdditional.maputnik.mapbox_access_token}
|
||||
>
|
||||
<StringInput
|
||||
value={(this.props.mapStyle.metadata || {})['maputnik:openmaptiles_access_token']}
|
||||
onChange={this.changeMetadataProperty.bind(this, "maputnik:openmaptiles_access_token")}
|
||||
/>
|
||||
</InputBlock>
|
||||
<InputBlock
|
||||
label={fieldSpecAdditional.maputnik.maptiler_access_token.label}
|
||||
fieldSpec={fieldSpecAdditional.maputnik.maptiler_access_token}
|
||||
>
|
||||
<StringInput
|
||||
value={(this.props.mapStyle.metadata || {})['maputnik:mapbox_access_token']}
|
||||
onChange={this.changeMetadataProperty.bind(this, "maputnik:mapbox_access_token")}
|
||||
/>
|
||||
</InputBlock>
|
||||
<InputBlock
|
||||
label={fieldSpecAdditional.maputnik.thunderforest_access_token.label}
|
||||
fieldSpec={fieldSpecAdditional.maputnik.thunderforest_access_token}
|
||||
>
|
||||
<StringInput
|
||||
value={(this.props.mapStyle.metadata || {})['maputnik:thunderforest_access_token']}
|
||||
onChange={this.changeMetadataProperty.bind(this, "maputnik:thunderforest_access_token")}
|
||||
/>
|
||||
</InputBlock>
|
||||
</div>
|
||||
|
||||
<Button onClick={this.downloadStyle.bind(this)}>
|
||||
<MdFileDownload />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="maputnik-modal-section">
|
||||
<h4>Save style</h4>
|
||||
<Gist mapStyle={this.props.mapStyle} onStyleChanged={this.props.onStyleChanged}/>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
}
|
||||
|
||||
45
src/components/modals/LoadingModal.jsx
Normal file
45
src/components/modals/LoadingModal.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import Button from '../Button'
|
||||
import Modal from './Modal'
|
||||
|
||||
|
||||
class LoadingModal extends React.Component {
|
||||
static propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
message: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
underlayOnClick(e) {
|
||||
// This stops click events falling through to underlying modals.
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Modal
|
||||
data-wd-key="loading-modal"
|
||||
isOpen={this.props.isOpen}
|
||||
underlayClickExits={false}
|
||||
underlayProps={{
|
||||
onClick: (e) => underlayProps(e)
|
||||
}}
|
||||
closeable={false}
|
||||
title={this.props.title}
|
||||
onOpenToggle={() => this.props.onCancel()}
|
||||
>
|
||||
<p>
|
||||
{this.props.message}
|
||||
</p>
|
||||
<p className="maputnik-dialog__buttons">
|
||||
<Button onClick={(e) => this.props.onCancel(e)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</p>
|
||||
</Modal>
|
||||
}
|
||||
}
|
||||
|
||||
export default LoadingModal
|
||||
@@ -1,33 +1,74 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import CloseIcon from 'react-icons/lib/md/close'
|
||||
import Overlay from './Overlay'
|
||||
import {MdClose} from 'react-icons/md'
|
||||
import AriaModal from 'react-aria-modal'
|
||||
import classnames from 'classnames';
|
||||
|
||||
|
||||
class Modal extends React.Component {
|
||||
static propTypes = {
|
||||
"data-wd-key": PropTypes.string,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
onOpenToggle: PropTypes.func.isRequired,
|
||||
children: PropTypes.node,
|
||||
underlayClickExits: PropTypes.bool,
|
||||
underlayProps: PropTypes.object,
|
||||
className: PropTypes.string,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
underlayClickExits: true
|
||||
}
|
||||
|
||||
// See <https://github.com/maputnik/editor/issues/416>
|
||||
onClose = () => {
|
||||
if (document.activeElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
setImmediate(() => {
|
||||
this.props.onOpenToggle(false);
|
||||
});
|
||||
}
|
||||
|
||||
getApplicationNode() {
|
||||
return document.getElementById('app');
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Overlay isOpen={this.props.isOpen}>
|
||||
<div className="maputnik-modal">
|
||||
<header className="maputnik-modal-header">
|
||||
<h1 className="maputnik-modal-header-title">{this.props.title}</h1>
|
||||
<span className="maputnik-modal-header-space"></span>
|
||||
<a className="maputnik-modal-header-toggle"
|
||||
onClick={() => this.props.onOpenToggle(false)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</a>
|
||||
</header>
|
||||
<div className="maputnik-modal-scroller">
|
||||
<div className="maputnik-modal-content">{this.props.children}</div>
|
||||
if(this.props.isOpen) {
|
||||
return <AriaModal
|
||||
titleText={this.props.title}
|
||||
underlayClickExits={this.props.underlayClickExits}
|
||||
underlayProps={this.props.underlayProps}
|
||||
getApplicationNode={this.getApplicationNode}
|
||||
data-wd-key={this.props["data-wd-key"]}
|
||||
verticallyCenter={true}
|
||||
onExit={this.onClose}
|
||||
>
|
||||
<div className={classnames("maputnik-modal", this.props.className)}
|
||||
data-wd-key={this.props["data-wd-key"]}
|
||||
>
|
||||
<header className="maputnik-modal-header">
|
||||
<h1 className="maputnik-modal-header-title">{this.props.title}</h1>
|
||||
<span className="maputnik-modal-header-space"></span>
|
||||
<button className="maputnik-modal-header-toggle"
|
||||
onClick={this.onClose}
|
||||
data-wd-key={this.props["data-wd-key"]+".close-modal"}
|
||||
>
|
||||
<MdClose />
|
||||
</button>
|
||||
</header>
|
||||
<div className="maputnik-modal-scroller">
|
||||
<div className="maputnik-modal-content">{this.props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Overlay>
|
||||
</AriaModal>
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import LoadingModal from './LoadingModal'
|
||||
import Modal from './Modal'
|
||||
import Button from '../Button'
|
||||
import FileReaderInput from 'react-file-reader-input'
|
||||
import request from 'request'
|
||||
import UrlInput from '../inputs/UrlInput'
|
||||
|
||||
import FileUploadIcon from 'react-icons/lib/md/file-upload'
|
||||
import AddIcon from 'react-icons/lib/md/add-circle-outline'
|
||||
import {MdFileUpload} from 'react-icons/md'
|
||||
import {MdAddCircleOutline} from 'react-icons/md'
|
||||
|
||||
import style from '../../libs/style.js'
|
||||
import publicStyles from '../../config/styles.json'
|
||||
@@ -23,18 +24,20 @@ class PublicStyle extends React.Component {
|
||||
return <div className="maputnik-public-style">
|
||||
<Button
|
||||
className="maputnik-public-style-button"
|
||||
aria-label={this.props.title}
|
||||
onClick={() => this.props.onSelect(this.props.url)}
|
||||
>
|
||||
<header className="maputnik-public-style-header">
|
||||
<h4>{this.props.title}</h4>
|
||||
<span className="maputnik-space" />
|
||||
<AddIcon />
|
||||
<MdAddCircleOutline />
|
||||
</header>
|
||||
<img
|
||||
<div
|
||||
className="maputnik-public-style-thumbnail"
|
||||
src={this.props.thumbnailUrl}
|
||||
alt={this.props.title}
|
||||
/>
|
||||
style={{
|
||||
backgroundImage: `url(${this.props.thumbnailUrl})`
|
||||
}}
|
||||
></div>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -49,7 +52,9 @@ class OpenModal extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
this.state = {
|
||||
styleUrl: ""
|
||||
};
|
||||
}
|
||||
|
||||
clearError() {
|
||||
@@ -58,30 +63,71 @@ class OpenModal extends React.Component {
|
||||
})
|
||||
}
|
||||
|
||||
onStyleSelect(styleUrl) {
|
||||
onCancelActiveRequest(e) {
|
||||
// Else the click propagates to the underlying modal
|
||||
if(e) e.stopPropagation();
|
||||
|
||||
if(this.state.activeRequest) {
|
||||
this.state.activeRequest.abort();
|
||||
this.setState({
|
||||
activeRequest: null,
|
||||
activeRequestUrl: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onStyleSelect = (styleUrl) => {
|
||||
this.clearError();
|
||||
|
||||
request({
|
||||
url: styleUrl,
|
||||
withCredentials: false,
|
||||
}, (error, response, body) => {
|
||||
if (!error && response.statusCode == 200) {
|
||||
const mapStyle = style.ensureStyleValidity(JSON.parse(body))
|
||||
console.log('Loaded style ', mapStyle.id)
|
||||
this.props.onStyleOpen(mapStyle)
|
||||
this.onOpenToggle()
|
||||
} else {
|
||||
console.warn('Could not open the style URL', styleUrl)
|
||||
let canceled;
|
||||
|
||||
const activeRequest = fetch(styleUrl, {
|
||||
mode: 'cors',
|
||||
credentials: "same-origin"
|
||||
})
|
||||
.then(function(response) {
|
||||
return response.json();
|
||||
})
|
||||
.then((body) => {
|
||||
if(canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
activeRequest: null,
|
||||
activeRequestUrl: null
|
||||
});
|
||||
|
||||
const mapStyle = style.ensureStyleValidity(body)
|
||||
console.log('Loaded style ', mapStyle.id)
|
||||
this.props.onStyleOpen(mapStyle)
|
||||
this.onOpenToggle()
|
||||
})
|
||||
.catch((err) => {
|
||||
this.setState({
|
||||
error: `Failed to load: '${styleUrl}'`,
|
||||
activeRequest: null,
|
||||
activeRequestUrl: null
|
||||
});
|
||||
console.error(err);
|
||||
console.warn('Could not open the style URL', styleUrl)
|
||||
})
|
||||
|
||||
this.setState({
|
||||
activeRequest: {
|
||||
abort: function() {
|
||||
canceled = true;
|
||||
}
|
||||
},
|
||||
activeRequestUrl: styleUrl
|
||||
})
|
||||
}
|
||||
|
||||
onOpenUrl() {
|
||||
const url = this.styleUrlElement.value;
|
||||
this.onStyleSelect(url);
|
||||
onOpenUrl = (url) => {
|
||||
this.onStyleSelect(this.state.styleUrl);
|
||||
}
|
||||
|
||||
onUpload(_, files) {
|
||||
onUpload = (_, files) => {
|
||||
const [e, file] = files[0];
|
||||
const reader = new FileReader();
|
||||
|
||||
@@ -107,10 +153,19 @@ class OpenModal extends React.Component {
|
||||
}
|
||||
|
||||
onOpenToggle() {
|
||||
this.setState({
|
||||
styleUrl: ""
|
||||
});
|
||||
this.clearError();
|
||||
this.props.onOpenToggle();
|
||||
}
|
||||
|
||||
onChangeUrl = (url) => {
|
||||
this.setState({
|
||||
styleUrl: url,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const styleOptions = publicStyles.map(style => {
|
||||
return <PublicStyle
|
||||
@@ -118,7 +173,7 @@ class OpenModal extends React.Component {
|
||||
url={style.url}
|
||||
title={style.title}
|
||||
thumbnailUrl={style.thumbnail}
|
||||
onSelect={this.onStyleSelect.bind(this)}
|
||||
onSelect={this.onStyleSelect}
|
||||
/>
|
||||
})
|
||||
|
||||
@@ -132,41 +187,65 @@ class OpenModal extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
return <Modal
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={() => this.onOpenToggle()}
|
||||
title={'Open Style'}
|
||||
>
|
||||
{errorElement}
|
||||
<section className="maputnik-modal-section">
|
||||
<h2>Upload Style</h2>
|
||||
<p>Upload a JSON style from your computer.</p>
|
||||
<FileReaderInput onChange={this.onUpload.bind(this)}>
|
||||
<Button className="maputnik-upload-button"><FileUploadIcon /> Upload</Button>
|
||||
</FileReaderInput>
|
||||
</section>
|
||||
return (
|
||||
<div>
|
||||
<Modal
|
||||
data-wd-key="open-modal"
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={() => this.onOpenToggle()}
|
||||
title={'Open Style'}
|
||||
>
|
||||
{errorElement}
|
||||
<section className="maputnik-modal-section">
|
||||
<h2>Upload Style</h2>
|
||||
<p>Upload a JSON style from your computer.</p>
|
||||
<FileReaderInput onChange={this.onUpload} tabIndex="-1">
|
||||
<Button className="maputnik-upload-button"><MdFileUpload /> Upload</Button>
|
||||
</FileReaderInput>
|
||||
</section>
|
||||
|
||||
<section className="maputnik-modal-section">
|
||||
<h2>Load from URL</h2>
|
||||
<p>
|
||||
Load from a URL. Note that the URL must have <a href="https://enable-cors.org" target="_blank" rel="noopener noreferrer">CORS enabled</a>.
|
||||
</p>
|
||||
<input type="text" ref={(input) => this.styleUrlElement = input} className="maputnik-input" placeholder="Enter URL..."/>
|
||||
<div>
|
||||
<Button className="maputnik-big-button" onClick={this.onOpenUrl.bind(this)}>Open URL</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="maputnik-modal-section">
|
||||
<h2>Load from URL</h2>
|
||||
<p>
|
||||
Load from a URL. Note that the URL must have <a href="https://enable-cors.org" target="_blank" rel="noopener noreferrer">CORS enabled</a>.
|
||||
</p>
|
||||
<UrlInput
|
||||
data-wd-key="open-modal.url.input"
|
||||
type="text"
|
||||
className="maputnik-input"
|
||||
default="Enter URL..."
|
||||
value={this.state.styleUrl}
|
||||
onInput={this.onChangeUrl}
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
data-wd-key="open-modal.url.button"
|
||||
className="maputnik-big-button"
|
||||
onClick={this.onOpenUrl}
|
||||
disabled={this.state.styleUrl.length < 1}
|
||||
>Open URL</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="maputnik-modal-section maputnik-modal-section--shrink">
|
||||
<h2>Gallery Styles</h2>
|
||||
<p>
|
||||
Open one of the publicly available styles to start from.
|
||||
</p>
|
||||
<div className="maputnik-style-gallery-container">
|
||||
{styleOptions}
|
||||
</div>
|
||||
</section>
|
||||
</Modal>
|
||||
<section className="maputnik-modal-section maputnik-modal-section--shrink">
|
||||
<h2>Gallery Styles</h2>
|
||||
<p>
|
||||
Open one of the publicly available styles to start from.
|
||||
</p>
|
||||
<div className="maputnik-style-gallery-container">
|
||||
{styleOptions}
|
||||
</div>
|
||||
</section>
|
||||
</Modal>
|
||||
|
||||
<LoadingModal
|
||||
isOpen={!!this.state.activeRequest}
|
||||
title={'Loading style'}
|
||||
onCancel={(e) => this.onCancelActiveRequest(e)}
|
||||
message={"Loading: "+this.state.activeRequestUrl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
|
||||
class Overlay extends React.Component {
|
||||
static propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
children: PropTypes.element.isRequired
|
||||
}
|
||||
|
||||
render() {
|
||||
let overlayStyle = {}
|
||||
if(!this.props.isOpen) {
|
||||
overlayStyle['display'] = 'none';
|
||||
}
|
||||
|
||||
return <div className={"maputnik-overlay"} style={overlayStyle}>
|
||||
<div className={"maputnik-overlay-viewport"} />
|
||||
{this.props.children}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default Overlay
|
||||
@@ -1,102 +1,266 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import styleSpec from '@mapbox/mapbox-gl-style-spec/style-spec'
|
||||
import {latest} from '@mapbox/mapbox-gl-style-spec'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import ArrayInput from '../inputs/ArrayInput'
|
||||
import NumberInput from '../inputs/NumberInput'
|
||||
import StringInput from '../inputs/StringInput'
|
||||
import UrlInput from '../inputs/UrlInput'
|
||||
import SelectInput from '../inputs/SelectInput'
|
||||
import EnumInput from '../inputs/EnumInput'
|
||||
import ColorField from '../fields/ColorField'
|
||||
import Modal from './Modal'
|
||||
import fieldSpecAdditional from '../../libs/field-spec-additional'
|
||||
|
||||
class SettingsModal extends React.Component {
|
||||
static propTypes = {
|
||||
mapStyle: PropTypes.object.isRequired,
|
||||
onStyleChanged: PropTypes.func.isRequired,
|
||||
onChangeMetadataProperty: PropTypes.func.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onOpenToggle: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
changeTransitionProperty(property, value) {
|
||||
const transition = {
|
||||
...this.props.mapStyle.transition,
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
delete transition[property];
|
||||
}
|
||||
else {
|
||||
transition[property] = value;
|
||||
}
|
||||
|
||||
this.props.onStyleChanged({
|
||||
...this.props.mapStyle,
|
||||
transition,
|
||||
});
|
||||
}
|
||||
|
||||
changeLightProperty(property, value) {
|
||||
const light = {
|
||||
...this.props.mapStyle.light,
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
delete light[property];
|
||||
}
|
||||
else {
|
||||
light[property] = value;
|
||||
}
|
||||
|
||||
this.props.onStyleChanged({
|
||||
...this.props.mapStyle,
|
||||
light,
|
||||
});
|
||||
}
|
||||
|
||||
changeStyleProperty(property, value) {
|
||||
const changedStyle = {
|
||||
...this.props.mapStyle,
|
||||
[property]: value
|
||||
}
|
||||
this.props.onStyleChanged(changedStyle)
|
||||
}
|
||||
};
|
||||
|
||||
changeMetadataProperty(property, value) {
|
||||
const changedStyle = {
|
||||
...this.props.mapStyle,
|
||||
metadata: {
|
||||
...this.props.mapStyle.metadata,
|
||||
[property]: value
|
||||
}
|
||||
if (value === undefined) {
|
||||
delete changedStyle[property];
|
||||
}
|
||||
this.props.onStyleChanged(changedStyle)
|
||||
else {
|
||||
changedStyle[property] = value;
|
||||
}
|
||||
this.props.onStyleChanged(changedStyle);
|
||||
}
|
||||
|
||||
render() {
|
||||
const metadata = this.props.mapStyle.metadata || {}
|
||||
const {onChangeMetadataProperty, mapStyle} = this.props;
|
||||
const inputProps = { }
|
||||
|
||||
const light = this.props.mapStyle.light || {};
|
||||
const transition = this.props.mapStyle.transition || {};
|
||||
|
||||
return <Modal
|
||||
data-wd-key="modal-settings"
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={this.props.onOpenToggle}
|
||||
title={'Style Settings'}
|
||||
>
|
||||
<div style={{minWidth: 350}}>
|
||||
<InputBlock label={"Name"} doc={styleSpec.latest.$root.name.doc}>
|
||||
<div className="modal-settings">
|
||||
<InputBlock label={"Name"} fieldSpec={latest.$root.name}>
|
||||
<StringInput {...inputProps}
|
||||
data-wd-key="modal-settings.name"
|
||||
value={this.props.mapStyle.name}
|
||||
onChange={this.changeStyleProperty.bind(this, "name")}
|
||||
/>
|
||||
</InputBlock>
|
||||
<InputBlock label={"Owner"} doc={"Owner ID of the style. Used by Mapbox or future style APIs."}>
|
||||
<InputBlock label={"Owner"} fieldSpec={{doc: "Owner ID of the style. Used by Mapbox or future style APIs."}}>
|
||||
<StringInput {...inputProps}
|
||||
data-wd-key="modal-settings.owner"
|
||||
value={this.props.mapStyle.owner}
|
||||
onChange={this.changeStyleProperty.bind(this, "owner")}
|
||||
/>
|
||||
</InputBlock>
|
||||
<InputBlock label={"Sprite URL"} doc={styleSpec.latest.$root.sprite.doc}>
|
||||
<StringInput {...inputProps}
|
||||
<InputBlock label={"Sprite URL"} fieldSpec={latest.$root.sprite}>
|
||||
<UrlInput {...inputProps}
|
||||
data-wd-key="modal-settings.sprite"
|
||||
value={this.props.mapStyle.sprite}
|
||||
onChange={this.changeStyleProperty.bind(this, "sprite")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock label={"Glyphs URL"} doc={styleSpec.latest.$root.glyphs.doc}>
|
||||
<StringInput {...inputProps}
|
||||
<InputBlock label={"Glyphs URL"} fieldSpec={latest.$root.glyphs}>
|
||||
<UrlInput {...inputProps}
|
||||
data-wd-key="modal-settings.glyphs"
|
||||
value={this.props.mapStyle.glyphs}
|
||||
onChange={this.changeStyleProperty.bind(this, "glyphs")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock label={"Mapbox Access Token"} doc={"Public access token for Mapbox services."}>
|
||||
<InputBlock
|
||||
label={fieldSpecAdditional.maputnik.mapbox_access_token.label}
|
||||
fieldSpec={fieldSpecAdditional.maputnik.mapbox_access_token}
|
||||
>
|
||||
<StringInput {...inputProps}
|
||||
data-wd-key="modal-settings.maputnik:mapbox_access_token"
|
||||
value={metadata['maputnik:mapbox_access_token']}
|
||||
onChange={this.changeMetadataProperty.bind(this, "maputnik:mapbox_access_token")}
|
||||
onChange={onChangeMetadataProperty.bind(this, "maputnik:mapbox_access_token")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock label={"OpenMapTiles Access Token"} doc={"Public access token for the OpenMapTiles CDN."}>
|
||||
<InputBlock
|
||||
label={fieldSpecAdditional.maputnik.maptiler_access_token.label}
|
||||
fieldSpec={fieldSpecAdditional.maputnik.maptiler_access_token}
|
||||
>
|
||||
<StringInput {...inputProps}
|
||||
data-wd-key="modal-settings.maputnik:openmaptiles_access_token"
|
||||
value={metadata['maputnik:openmaptiles_access_token']}
|
||||
onChange={this.changeMetadataProperty.bind(this, "maputnik:openmaptiles_access_token")}
|
||||
onChange={onChangeMetadataProperty.bind(this, "maputnik:openmaptiles_access_token")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock label={"Style Renderer"} doc={"Choose the default Maputnik renderer for this style."}>
|
||||
<InputBlock
|
||||
label={fieldSpecAdditional.maputnik.thunderforest_access_token.label}
|
||||
fieldSpec={fieldSpecAdditional.maputnik.thunderforest_access_token}
|
||||
>
|
||||
<StringInput {...inputProps}
|
||||
data-wd-key="modal-settings.maputnik:thunderforest_access_token"
|
||||
value={metadata['maputnik:thunderforest_access_token']}
|
||||
onChange={onChangeMetadataProperty.bind(this, "maputnik:thunderforest_access_token")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock label={"Center"} fieldSpec={latest.$root.center}>
|
||||
<ArrayInput
|
||||
length={2}
|
||||
type="number"
|
||||
value={mapStyle.center}
|
||||
default={latest.$root.center.default || [0, 0]}
|
||||
onChange={this.changeStyleProperty.bind(this, "center")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock label={"Zoom"} fieldSpec={latest.$root.zoom}>
|
||||
<NumberInput
|
||||
{...inputProps}
|
||||
value={mapStyle.zoom}
|
||||
default={latest.$root.zoom.default || 0}
|
||||
onChange={this.changeStyleProperty.bind(this, "zoom")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock label={"Bearing"} fieldSpec={latest.$root.bearing}>
|
||||
<NumberInput
|
||||
{...inputProps}
|
||||
value={mapStyle.bearing}
|
||||
default={latest.$root.bearing.default}
|
||||
onChange={this.changeStyleProperty.bind(this, "bearing")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock label={"Pitch"} fieldSpec={latest.$root.pitch}>
|
||||
<NumberInput
|
||||
{...inputProps}
|
||||
value={mapStyle.pitch}
|
||||
default={latest.$root.pitch.default}
|
||||
onChange={this.changeStyleProperty.bind(this, "pitch")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock label={"Light anchor"} fieldSpec={latest.light.anchor}>
|
||||
<EnumInput
|
||||
{...inputProps}
|
||||
value={light.anchor}
|
||||
options={Object.keys(latest.light.anchor.values)}
|
||||
default={latest.light.anchor.default}
|
||||
onChange={this.changeLightProperty.bind(this, "anchor")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock label={"Light color"} fieldSpec={latest.light.color}>
|
||||
<ColorField
|
||||
{...inputProps}
|
||||
value={light.color}
|
||||
default={latest.light.color.default}
|
||||
onChange={this.changeLightProperty.bind(this, "color")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock label={"Light intensity"} fieldSpec={latest.light.intensity}>
|
||||
<NumberInput
|
||||
{...inputProps}
|
||||
value={light.intensity}
|
||||
default={latest.light.intensity.default}
|
||||
onChange={this.changeLightProperty.bind(this, "intensity")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock label={"Light position"} fieldSpec={latest.light.position}>
|
||||
<ArrayInput
|
||||
{...inputProps}
|
||||
type="number"
|
||||
length={latest.light.position.length}
|
||||
value={light.position}
|
||||
default={latest.light.position.default}
|
||||
onChange={this.changeLightProperty.bind(this, "position")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock label={"Transition delay"} fieldSpec={latest.transition.delay}>
|
||||
<NumberInput
|
||||
{...inputProps}
|
||||
value={transition.delay}
|
||||
default={latest.transition.delay.default}
|
||||
onChange={this.changeTransitionProperty.bind(this, "delay")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock label={"Transition duration"} fieldSpec={latest.transition.duration}>
|
||||
<NumberInput
|
||||
{...inputProps}
|
||||
value={transition.duration}
|
||||
default={latest.transition.duration.default}
|
||||
onChange={this.changeTransitionProperty.bind(this, "duration")}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
<InputBlock
|
||||
label={fieldSpecAdditional.maputnik.style_renderer.label}
|
||||
fieldSpec={fieldSpecAdditional.maputnik.style_renderer}
|
||||
>
|
||||
<SelectInput {...inputProps}
|
||||
data-wd-key="modal-settings.maputnik:renderer"
|
||||
options={[
|
||||
['mbgljs', 'MapboxGL JS'],
|
||||
['ol3', 'Open Layers 3'],
|
||||
['ol', 'Open Layers (experimental)'],
|
||||
]}
|
||||
value={metadata['maputnik:renderer'] || 'mbgljs'}
|
||||
onChange={this.changeMetadataProperty.bind(this, 'maputnik:renderer')}
|
||||
onChange={onChangeMetadataProperty.bind(this, 'maputnik:renderer')}
|
||||
/>
|
||||
</InputBlock>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
|
||||
72
src/components/modals/ShortcutsModal.jsx
Normal file
72
src/components/modals/ShortcutsModal.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import Modal from './Modal'
|
||||
|
||||
|
||||
class ShortcutsModal extends React.Component {
|
||||
static propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onOpenToggle: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
render() {
|
||||
const help = [
|
||||
{
|
||||
key: "?",
|
||||
text: "Shortcuts menu"
|
||||
},
|
||||
{
|
||||
key: "o",
|
||||
text: "Open modal"
|
||||
},
|
||||
{
|
||||
key: "e",
|
||||
text: "Export modal"
|
||||
},
|
||||
{
|
||||
key: "d",
|
||||
text: "Data Sources modal"
|
||||
},
|
||||
{
|
||||
key: "s",
|
||||
text: "Style Settings modal"
|
||||
},
|
||||
{
|
||||
key: "i",
|
||||
text: "Toggle inspect"
|
||||
},
|
||||
{
|
||||
key: "m",
|
||||
text: "Focus map"
|
||||
},
|
||||
{
|
||||
key: "!",
|
||||
text: "Debug modal"
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
return <Modal
|
||||
data-wd-key="shortcuts-modal"
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={this.props.onOpenToggle}
|
||||
title={'Shortcuts'}
|
||||
>
|
||||
<div className="maputnik-modal-section maputnik-modal-shortcuts">
|
||||
<p>
|
||||
Press <code>ESC</code> to lose focus of any active elements, then press one of:
|
||||
</p>
|
||||
<ul>
|
||||
{help.map((item) => {
|
||||
return <li key={item.key}>
|
||||
<code>{item.key}</code> {item.text}
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
}
|
||||
|
||||
export default ShortcutsModal
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import styleSpec from '@mapbox/mapbox-gl-style-spec/style-spec'
|
||||
import {latest} from '@mapbox/mapbox-gl-style-spec'
|
||||
import Modal from './Modal'
|
||||
import Button from '../Button'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
@@ -12,8 +12,7 @@ import style from '../../libs/style'
|
||||
import { deleteSource, addSource, changeSource } from '../../libs/source'
|
||||
import publicSources from '../../config/tilesets.json'
|
||||
|
||||
import AddIcon from 'react-icons/lib/md/add-circle-outline'
|
||||
import DeleteIcon from 'react-icons/lib/md/delete'
|
||||
import {MdAddCircleOutline, MdDelete} from 'react-icons/md'
|
||||
|
||||
class PublicSource extends React.Component {
|
||||
static propTypes = {
|
||||
@@ -34,7 +33,7 @@ class PublicSource extends React.Component {
|
||||
<p className="maputnik-public-source-id">#{this.props.id}</p>
|
||||
</div>
|
||||
<span className="maputnik-space" />
|
||||
<AddIcon />
|
||||
<MdAddCircleOutline />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -45,11 +44,28 @@ function editorMode(source) {
|
||||
if(source.tiles) return 'tilexyz_raster'
|
||||
return 'tilejson_raster'
|
||||
}
|
||||
if(source.type === 'raster-dem') {
|
||||
if(source.tiles) return 'tilexyz_raster-dem'
|
||||
return 'tilejson_raster-dem'
|
||||
}
|
||||
if(source.type === 'vector') {
|
||||
if(source.tiles) return 'tilexyz_vector'
|
||||
return 'tilejson_vector'
|
||||
}
|
||||
if(source.type === 'geojson') return 'geojson'
|
||||
if(source.type === 'geojson') {
|
||||
if (typeof(source.data) === "string") {
|
||||
return 'geojson_url';
|
||||
}
|
||||
else {
|
||||
return 'geojson_json';
|
||||
}
|
||||
}
|
||||
if(source.type === 'image') {
|
||||
return 'image';
|
||||
}
|
||||
if(source.type === 'video') {
|
||||
return 'video';
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -72,7 +88,7 @@ class ActiveSourceTypeEditor extends React.Component {
|
||||
onClick={()=> this.props.onDelete(this.props.sourceId)}
|
||||
style={{backgroundColor: 'transparent'}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
<MdDelete />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="maputnik-active-source-type-editor-content">
|
||||
@@ -102,64 +118,128 @@ class AddSource extends React.Component {
|
||||
|
||||
defaultSource(mode) {
|
||||
const source = (this.state || {}).source || {}
|
||||
const {protocol} = window.location;
|
||||
|
||||
switch(mode) {
|
||||
case 'geojson': return {
|
||||
case 'geojson_url': return {
|
||||
type: 'geojson',
|
||||
data: source.data || 'http://localhost:3000/geojson.json'
|
||||
data: `${protocol}//localhost:3000/geojson.json`
|
||||
}
|
||||
case 'geojson_json': return {
|
||||
type: 'geojson',
|
||||
data: {}
|
||||
}
|
||||
case 'tilejson_vector': return {
|
||||
type: 'vector',
|
||||
url: source.url || 'http://localhost:3000/tilejson.json'
|
||||
url: source.url || `${protocol}//localhost:3000/tilejson.json`
|
||||
}
|
||||
case 'tilexyz_vector': return {
|
||||
type: 'vector',
|
||||
tiles: source.tiles || ['http://localhost:3000/{x}/{y}/{z}.pbf'],
|
||||
tiles: source.tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.pbf`],
|
||||
minZoom: source.minzoom || 0,
|
||||
maxZoom: source.maxzoom || 14
|
||||
}
|
||||
case 'tilejson_raster': return {
|
||||
type: 'raster',
|
||||
url: source.url || 'http://localhost:3000/tilejson.json'
|
||||
url: source.url || `${protocol}//localhost:3000/tilejson.json`
|
||||
}
|
||||
case 'tilexyz_raster': return {
|
||||
type: 'raster',
|
||||
tiles: source.tiles || ['http://localhost:3000/{x}/{y}/{z}.pbf'],
|
||||
tiles: source.tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.pbf`],
|
||||
minzoom: source.minzoom || 0,
|
||||
maxzoom: source.maxzoom || 14
|
||||
}
|
||||
case 'tilejson_raster-dem': return {
|
||||
type: 'raster-dem',
|
||||
url: source.url || `${protocol}//localhost:3000/tilejson.json`
|
||||
}
|
||||
case 'tilexyz_raster-dem': return {
|
||||
type: 'raster-dem',
|
||||
tiles: source.tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.pbf`],
|
||||
minzoom: source.minzoom || 0,
|
||||
maxzoom: source.maxzoom || 14
|
||||
}
|
||||
case 'image': return {
|
||||
type: 'image',
|
||||
url: `${protocol}//localhost:3000/image.png`,
|
||||
coordinates: [
|
||||
[0,0],
|
||||
[0,0],
|
||||
[0,0],
|
||||
[0,0],
|
||||
],
|
||||
}
|
||||
case 'video': return {
|
||||
type: 'video',
|
||||
urls: [
|
||||
`${protocol}//localhost:3000/movie.mp4`
|
||||
],
|
||||
coordinates: [
|
||||
[0,0],
|
||||
[0,0],
|
||||
[0,0],
|
||||
[0,0],
|
||||
],
|
||||
}
|
||||
default: return {}
|
||||
}
|
||||
}
|
||||
|
||||
onAdd = () => {
|
||||
const {source, sourceId} = this.state;
|
||||
this.props.onAdd(sourceId, source);
|
||||
}
|
||||
|
||||
onChangeSource = (source) => {
|
||||
this.setState({source});
|
||||
}
|
||||
|
||||
render() {
|
||||
// Kind of a hack because the type changes, however maputnik has 1..n
|
||||
// options per type, for example
|
||||
//
|
||||
// - 'geojson' - 'GeoJSON (URL)' and 'GeoJSON (JSON)'
|
||||
// - 'raster' - 'Raster (TileJSON URL)' and 'Raster (XYZ URL)'
|
||||
//
|
||||
// So we just ignore the values entirely as they are self explanatory
|
||||
const sourceTypeFieldSpec = {
|
||||
doc: latest.source_vector.type.doc
|
||||
};
|
||||
|
||||
return <div className="maputnik-add-source">
|
||||
<InputBlock label={"Source ID"} doc={"Unique ID that identifies the source and is used in the layer to reference the source."}>
|
||||
<InputBlock label={"Source ID"} fieldSpec={{doc: "Unique ID that identifies the source and is used in the layer to reference the source."}}>
|
||||
<StringInput
|
||||
value={this.state.sourceId}
|
||||
onChange={v => this.setState({ sourceId: v})}
|
||||
/>
|
||||
</InputBlock>
|
||||
<InputBlock label={"Source Type"} doc={styleSpec.latest.source_vector.type.doc}>
|
||||
<InputBlock label={"Source Type"} fieldSpec={sourceTypeFieldSpec}>
|
||||
<SelectInput
|
||||
options={[
|
||||
['geojson', 'GeoJSON'],
|
||||
['geojson_json', 'GeoJSON (JSON)'],
|
||||
['geojson_url', 'GeoJSON (URL)'],
|
||||
['tilejson_vector', 'Vector (TileJSON URL)'],
|
||||
['tilexyz_vector', 'Vector (XYZ URLs)'],
|
||||
['tilejson_raster', 'Raster (TileJSON URL)'],
|
||||
['tilexyz_raster', 'Raster (XYZ URL)'],
|
||||
['tilejson_raster-dem', 'Raster DEM (TileJSON URL)'],
|
||||
['tilexyz_raster-dem', 'Raster DEM (XYZ URLs)'],
|
||||
['image', 'Image'],
|
||||
['video', 'Video'],
|
||||
]}
|
||||
onChange={mode => this.setState({mode: mode, source: this.defaultSource(mode)})}
|
||||
value={this.state.mode}
|
||||
/>
|
||||
</InputBlock>
|
||||
<SourceTypeEditor
|
||||
onChange={src => this.setState({ source: src })}
|
||||
onChange={this.onChangeSource}
|
||||
mode={this.state.mode}
|
||||
source={this.state.source}
|
||||
/>
|
||||
<Button
|
||||
className="maputnik-add-source-button"
|
||||
onClick={() => this.props.onAdd(this.state.sourceId, this.state.source)}>
|
||||
onClick={this.onAdd}
|
||||
>
|
||||
Add Source
|
||||
</Button>
|
||||
</div>
|
||||
@@ -218,11 +298,14 @@ class SourcesModal extends React.Component {
|
||||
<div className="maputnik-modal-section">
|
||||
<h4>Choose Public Source</h4>
|
||||
<p>
|
||||
Add one of the publicly availble sources to your style.
|
||||
Add one of the publicly available sources to your style.
|
||||
</p>
|
||||
<div style={{maxwidth: 500}}>
|
||||
<div className="maputnik-public-sources" style={{maxwidth: 500}}>
|
||||
{tilesetOptions}
|
||||
</div>
|
||||
<p>
|
||||
<strong>Note:</strong> Some of the tilesets are not optimised for online use, and as a result the file sizes of the tiles can be quite large (heavy) for online vector rendering. Please review any tilesets before use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="maputnik-modal-section">
|
||||
|
||||
39
src/components/modals/SurveyModal.jsx
Normal file
39
src/components/modals/SurveyModal.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import Button from '../Button'
|
||||
import Modal from './Modal'
|
||||
|
||||
import logoImage from 'maputnik-design/logos/logo-color.svg'
|
||||
|
||||
class SurveyModal extends React.Component {
|
||||
static propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onOpenToggle: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
onClick = () => {
|
||||
window.open('https://gregorywolanski.typeform.com/to/cPgaSY', '_blank');
|
||||
|
||||
this.props.onOpenToggle();
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Modal
|
||||
data-wd-key="modal-survey"
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={this.props.onOpenToggle}
|
||||
title="Maputnik Survey"
|
||||
>
|
||||
<div className="maputnik-modal-survey">
|
||||
<div className="maputnik-modal-survey__logo" dangerouslySetInnerHTML={{__html: logoImage}} />
|
||||
<h1>You + Maputnik = Maputnik better for you</h1>
|
||||
<p className="maputnik-modal-survey__description">We don’t track you, so we don’t know how you use Maputnik. Help us make Maputnik better for you by completing a 7–minute survey carried out by our contributing designer.</p>
|
||||
<Button onClick={this.onClick} className="maputnik-big-button maputnik-white-button maputnik-wide-button">Take the Maputnik Survey</Button>
|
||||
<p className="maputnik-modal-survey__footnote">It takes 7 minutes, tops! Every question is optional.</p>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
}
|
||||
|
||||
export default SurveyModal
|
||||
@@ -1,26 +1,36 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import styleSpec from '@mapbox/mapbox-gl-style-spec/style-spec'
|
||||
import {latest} from '@mapbox/mapbox-gl-style-spec'
|
||||
import InputBlock from '../inputs/InputBlock'
|
||||
import StringInput from '../inputs/StringInput'
|
||||
import UrlInput from '../inputs/UrlInput'
|
||||
import NumberInput from '../inputs/NumberInput'
|
||||
import SelectInput from '../inputs/SelectInput'
|
||||
import DynamicArrayInput from '../inputs/DynamicArrayInput'
|
||||
import ArrayInput from '../inputs/ArrayInput'
|
||||
import JSONEditor from '../layers/JSONEditor'
|
||||
|
||||
|
||||
class TileJSONSourceEditor extends React.Component {
|
||||
static propTypes = {
|
||||
source: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
children: PropTypes.node,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <InputBlock label={"TileJSON URL"} doc={styleSpec.latest.source_vector.url.doc}>
|
||||
<StringInput
|
||||
value={this.props.source.url}
|
||||
onChange={url => this.props.onChange({
|
||||
...this.props.source,
|
||||
url: url
|
||||
})}
|
||||
/>
|
||||
</InputBlock>
|
||||
return <div>
|
||||
<InputBlock label={"TileJSON URL"} fieldSpec={latest.source_vector.url}>
|
||||
<UrlInput
|
||||
value={this.props.source.url}
|
||||
onChange={url => this.props.onChange({
|
||||
...this.props.source,
|
||||
url: url
|
||||
})}
|
||||
/>
|
||||
</InputBlock>
|
||||
{this.props.children}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +38,7 @@ class TileURLSourceEditor extends React.Component {
|
||||
static propTypes = {
|
||||
source: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
children: PropTypes.node,
|
||||
}
|
||||
|
||||
changeTileUrl(idx, value) {
|
||||
@@ -43,8 +54,8 @@ class TileURLSourceEditor extends React.Component {
|
||||
const prefix = ['1st', '2nd', '3rd', '4th', '5th', '6th', '7th']
|
||||
const tiles = this.props.source.tiles || []
|
||||
return tiles.map((tileUrl, tileIndex) => {
|
||||
return <InputBlock key={tileIndex} label={prefix[tileIndex] + " Tile URL"} doc={styleSpec.latest.source_vector.tiles.doc}>
|
||||
<StringInput
|
||||
return <InputBlock key={tileIndex} label={prefix[tileIndex] + " Tile URL"} fieldSpec={latest.source_vector.tiles}>
|
||||
<UrlInput
|
||||
value={tileUrl}
|
||||
onChange={this.changeTileUrl.bind(this, tileIndex)}
|
||||
/>
|
||||
@@ -55,7 +66,7 @@ class TileURLSourceEditor extends React.Component {
|
||||
render() {
|
||||
return <div>
|
||||
{this.renderTileUrls()}
|
||||
<InputBlock label={"Min Zoom"} doc={styleSpec.latest.source_vector.minzoom.doc}>
|
||||
<InputBlock label={"Min Zoom"} fieldSpec={latest.source_vector.minzoom}>
|
||||
<NumberInput
|
||||
value={this.props.source.minzoom || 0}
|
||||
onChange={minzoom => this.props.onChange({
|
||||
@@ -64,7 +75,7 @@ class TileURLSourceEditor extends React.Component {
|
||||
})}
|
||||
/>
|
||||
</InputBlock>
|
||||
<InputBlock label={"Max Zoom"} doc={styleSpec.latest.source_vector.maxzoom.doc}>
|
||||
<InputBlock label={"Max Zoom"} fieldSpec={latest.source_vector.maxzoom}>
|
||||
<NumberInput
|
||||
value={this.props.source.maxzoom || 22}
|
||||
onChange={maxzoom => this.props.onChange({
|
||||
@@ -73,20 +84,115 @@ class TileURLSourceEditor extends React.Component {
|
||||
})}
|
||||
/>
|
||||
</InputBlock>
|
||||
{this.props.children}
|
||||
</div>
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
class GeoJSONSourceEditor extends React.Component {
|
||||
class ImageSourceEditor extends React.Component {
|
||||
static propTypes = {
|
||||
source: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <InputBlock label={"GeoJSON Data"} doc={styleSpec.latest.source_geojson.data.doc}>
|
||||
<StringInput
|
||||
const changeCoord = (idx, val) => {
|
||||
const coordinates = this.props.source.coordinates.slice(0);
|
||||
coordinates[idx] = val;
|
||||
|
||||
this.props.onChange({
|
||||
...this.props.source,
|
||||
coordinates,
|
||||
});
|
||||
}
|
||||
|
||||
return <div>
|
||||
<InputBlock label={"Image URL"} doc={latest.source_image.url.doc}>
|
||||
<UrlInput
|
||||
value={this.props.source.url}
|
||||
onChange={url => this.props.onChange({
|
||||
...this.props.source,
|
||||
url,
|
||||
})}
|
||||
/>
|
||||
</InputBlock>
|
||||
{["top left", "top right", "bottom right", "bottom left"].map((label, idx) => {
|
||||
return (
|
||||
<InputBlock label={`Coord ${label}`} key={label}>
|
||||
<ArrayInput
|
||||
length={2}
|
||||
type="number"
|
||||
value={this.props.source.coordinates[idx]}
|
||||
default={[0, 0]}
|
||||
onChange={(val) => changeCoord(idx, val)}
|
||||
/>
|
||||
</InputBlock>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
class VideoSourceEditor extends React.Component {
|
||||
static propTypes = {
|
||||
source: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
render() {
|
||||
const changeCoord = (idx, val) => {
|
||||
const coordinates = this.props.source.coordinates.slice(0);
|
||||
coordinates[idx] = val;
|
||||
|
||||
this.props.onChange({
|
||||
...this.props.source,
|
||||
coordinates,
|
||||
});
|
||||
}
|
||||
|
||||
const changeUrls = (urls) => {
|
||||
this.props.onChange({
|
||||
...this.props.source,
|
||||
urls,
|
||||
});
|
||||
}
|
||||
|
||||
return <div>
|
||||
<InputBlock label={"Video URL"} doc={latest.source_video.urls.doc}>
|
||||
<DynamicArrayInput
|
||||
type="string"
|
||||
value={this.props.source.urls}
|
||||
default={""}
|
||||
onChange={changeUrls}
|
||||
/>
|
||||
</InputBlock>
|
||||
{["top left", "top right", "bottom right", "bottom left"].map((label, idx) => {
|
||||
return (
|
||||
<InputBlock label={`Coord ${label}`} key={label}>
|
||||
<ArrayInput
|
||||
length={2}
|
||||
type="number"
|
||||
value={this.props.source.coordinates[idx]}
|
||||
default={[0, 0]}
|
||||
onChange={val => changeCoord(idx, val)}
|
||||
/>
|
||||
</InputBlock>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
class GeoJSONSourceUrlEditor extends React.Component {
|
||||
static propTypes = {
|
||||
source: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <InputBlock label={"GeoJSON URL"} fieldSpec={latest.source_geojson.data}>
|
||||
<UrlInput
|
||||
value={this.props.source.data}
|
||||
onChange={data => this.props.onChange({
|
||||
...this.props.source,
|
||||
@@ -97,6 +203,28 @@ class GeoJSONSourceEditor extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
class GeoJSONSourceJSONEditor extends React.Component {
|
||||
static propTypes = {
|
||||
source: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <InputBlock label={"GeoJSON"} fieldSpec={latest.source_geojson.data}>
|
||||
<JSONEditor
|
||||
layer={this.props.source.data}
|
||||
maxHeight={200}
|
||||
onChange={data => {
|
||||
this.props.onChange({
|
||||
...this.props.source,
|
||||
data,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</InputBlock>
|
||||
}
|
||||
}
|
||||
|
||||
class SourceTypeEditor extends React.Component {
|
||||
static propTypes = {
|
||||
mode: PropTypes.string.isRequired,
|
||||
@@ -110,11 +238,27 @@ class SourceTypeEditor extends React.Component {
|
||||
onChange: this.props.onChange,
|
||||
}
|
||||
switch(this.props.mode) {
|
||||
case 'geojson': return <GeoJSONSourceEditor {...commonProps} />
|
||||
case 'geojson_url': return <GeoJSONSourceUrlEditor {...commonProps} />
|
||||
case 'geojson_json': return <GeoJSONSourceJSONEditor {...commonProps} />
|
||||
case 'tilejson_vector': return <TileJSONSourceEditor {...commonProps} />
|
||||
case 'tilexyz_vector': return <TileURLSourceEditor {...commonProps} />
|
||||
case 'tilejson_raster': return <TileJSONSourceEditor {...commonProps} />
|
||||
case 'tilexyz_raster': return <TileURLSourceEditor {...commonProps} />
|
||||
case 'tilejson_raster-dem': return <TileJSONSourceEditor {...commonProps} />
|
||||
case 'tilexyz_raster-dem': return <TileURLSourceEditor {...commonProps}>
|
||||
<InputBlock label={"Encoding"} fieldSpec={latest.source_raster_dem.encoding}>
|
||||
<SelectInput
|
||||
options={Object.keys(latest.source_raster_dem.encoding.values)}
|
||||
onChange={encoding => this.props.onChange({
|
||||
...this.props.source,
|
||||
encoding: encoding
|
||||
})}
|
||||
value={this.props.source.encoding || latest.source_raster_dem.encoding.default}
|
||||
/>
|
||||
</InputBlock>
|
||||
</TileURLSourceEditor>
|
||||
case 'image': return <ImageSourceEditor {...commonProps} />
|
||||
case 'video': return <VideoSourceEditor {...commonProps} />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
20
src/components/util/SmallError.jsx
Normal file
20
src/components/util/SmallError.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import './SmallError.scss';
|
||||
|
||||
|
||||
class SmallError extends React.Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div className="SmallError">
|
||||
Error: {this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SmallError
|
||||
7
src/components/util/SmallError.scss
Normal file
7
src/components/util/SmallError.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
@import '../../styles/vars';
|
||||
|
||||
.SmallError {
|
||||
color: #E57373;
|
||||
font-size: $font-size-6;
|
||||
margin-top: $margin-2
|
||||
}
|
||||
154
src/components/util/codemirror-mgl.js
Normal file
154
src/components/util/codemirror-mgl.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import jsonlint from 'jsonlint';
|
||||
import CodeMirror from 'codemirror';
|
||||
import jsonToAst from 'json-to-ast';
|
||||
import {expression, validate, latest} from '@mapbox/mapbox-gl-style-spec';
|
||||
|
||||
|
||||
CodeMirror.defineMode("mgl", function(config, parserConfig) {
|
||||
// Just using the javascript mode with json enabled. Our logic is in the linter below.
|
||||
return CodeMirror.modes.javascript(
|
||||
{...config, json: true},
|
||||
parserConfig
|
||||
);
|
||||
});
|
||||
|
||||
CodeMirror.registerHelper("lint", "mgl", function(text, opts, doc) {
|
||||
const found = [];
|
||||
const {parser} = jsonlint;
|
||||
const {context} = opts;
|
||||
|
||||
parser.parseError = function(str, hash) {
|
||||
const loc = hash.loc;
|
||||
found.push({
|
||||
from: CodeMirror.Pos(loc.first_line - 1, loc.first_column),
|
||||
to: CodeMirror.Pos(loc.last_line - 1, loc.last_column),
|
||||
message: str
|
||||
});
|
||||
};
|
||||
try {
|
||||
parser.parse(text);
|
||||
}
|
||||
catch (e) {}
|
||||
|
||||
if (found.length > 0) {
|
||||
// JSON invalid so don't go any further
|
||||
return found;
|
||||
}
|
||||
|
||||
const ast = jsonToAst(text);
|
||||
const input = JSON.parse(text);
|
||||
|
||||
function getArrayPositionalFromAst (node, path) {
|
||||
if (!node) {
|
||||
return undefined;
|
||||
}
|
||||
else if (path.length < 1) {
|
||||
return node;
|
||||
}
|
||||
else if (!node.children) {
|
||||
return undefined;
|
||||
}
|
||||
else {
|
||||
const key = path[0];
|
||||
let newNode;
|
||||
if (key.match(/^[0-9]+$/)) {
|
||||
newNode = node.children[path[0]];
|
||||
}
|
||||
else {
|
||||
newNode = node.children.find(childNode => {
|
||||
return (
|
||||
childNode.key &&
|
||||
childNode.key.type === "Identifier" &&
|
||||
childNode.key.value === key
|
||||
);
|
||||
});
|
||||
if (newNode) {
|
||||
newNode = newNode.value;
|
||||
}
|
||||
}
|
||||
return getArrayPositionalFromAst(newNode, path.slice(1))
|
||||
}
|
||||
}
|
||||
|
||||
let out;
|
||||
if (context === "layer") {
|
||||
// Just an empty style so we can validate a layer.
|
||||
const errors = validate({
|
||||
"version": 8,
|
||||
"name": "Empty Style",
|
||||
"metadata": {},
|
||||
"sources": {},
|
||||
"sprite": "",
|
||||
"glyphs": "https://example.com/glyphs/{fontstack}/{range}.pbf",
|
||||
"layers": [
|
||||
input
|
||||
]
|
||||
});
|
||||
|
||||
if (errors) {
|
||||
out = {
|
||||
result: "error",
|
||||
value: errors
|
||||
.filter(err => {
|
||||
// Remove missing 'layer source' errors, because we don't include them
|
||||
if (err.message.match(/^layers\[0\]: source ".*" not found$/)) {
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.map(err => {
|
||||
// Remove the 'layers[0].' as we're validating the layer only here
|
||||
const errMessageParts = err.message.replace(/^layers\[0\]./, "").split(":");
|
||||
return {
|
||||
key: errMessageParts[0],
|
||||
message: errMessageParts[1],
|
||||
};
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (context === "expression") {
|
||||
out = expression.createExpression(input, opts.spec);
|
||||
}
|
||||
else {
|
||||
throw new Error(`Invalid context ${context}`);
|
||||
}
|
||||
|
||||
if (out.result === "error") {
|
||||
const errors = out.value;
|
||||
errors.forEach(error => {
|
||||
const {key, message} = error;
|
||||
|
||||
if (!key) {
|
||||
const lastLineHandle = doc.getLineHandle(doc.lastLine());
|
||||
const err = {
|
||||
from: CodeMirror.Pos(doc.firstLine(), 0),
|
||||
to: CodeMirror.Pos(doc.lastLine(), lastLineHandle.text.length),
|
||||
message: message,
|
||||
}
|
||||
found.push(err);
|
||||
}
|
||||
else if (key) {
|
||||
const path = key.replace(/^\[|\]$/g, "").split(/\.|[\[\]]+/).filter(Boolean)
|
||||
const parsedError = getArrayPositionalFromAst(ast, path);
|
||||
if (!parsedError) {
|
||||
console.warn("Something went wrong parsing error:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const {loc} = parsedError;
|
||||
const {start, end} = loc;
|
||||
|
||||
found.push({
|
||||
from: CodeMirror.Pos(start.line - 1, start.column),
|
||||
to: CodeMirror.Pos(end.line - 1, end.column),
|
||||
message: message,
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return found;
|
||||
});
|
||||
3
src/components/util/format.js
Normal file
3
src/components/util/format.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export function formatLayerId (id) {
|
||||
return id === "" ? "[empty_string]" : `'${id}'`;
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
{
|
||||
"version": 8,
|
||||
"name": "Empty Style",
|
||||
"metadata": {
|
||||
"mapbox:autocomposite": false,
|
||||
"mapbox:type": "template",
|
||||
"maputnik:renderer": "mbgljs",
|
||||
"openmaptiles:version": "3.x"
|
||||
},
|
||||
"sources": { },
|
||||
"glyphs": "https://demo.tileserver.org/fonts/{fontstack}/{range}.pbf",
|
||||
"sprites": "https://demo.tileserver.org/fonts/{fontstack}/{range}.pbf",
|
||||
"metadata": {},
|
||||
"sources": {},
|
||||
"sprite": "",
|
||||
"glyphs": "https://orangemug.github.io/font-glyphs/glyphs/{fontstack}/{range}.pbf",
|
||||
"layers": []
|
||||
}
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
"fill-extrusion-translate-anchor",
|
||||
"fill-extrusion-pattern",
|
||||
"fill-extrusion-height",
|
||||
"fill-extrusion-base"
|
||||
"fill-extrusion-base",
|
||||
"fill-extrusion-vertical-gradient"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -91,7 +92,8 @@
|
||||
"circle-stroke-width",
|
||||
"circle-pitch-scale",
|
||||
"circle-translate",
|
||||
"circle-translate-anchor"
|
||||
"circle-translate-anchor",
|
||||
"circle-pitch-alignment"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -104,7 +106,8 @@
|
||||
"fields": [
|
||||
"symbol-placement",
|
||||
"symbol-spacing",
|
||||
"symbol-avoid-edges"
|
||||
"symbol-avoid-edges",
|
||||
"symbol-z-order"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -125,17 +128,21 @@
|
||||
"text-justify",
|
||||
"text-anchor",
|
||||
"text-max-angle",
|
||||
"text-writing-mode",
|
||||
"text-rotate",
|
||||
"text-keep-upright",
|
||||
"text-transform",
|
||||
"text-offset",
|
||||
"text-optional"
|
||||
"text-optional",
|
||||
"text-variable-anchor",
|
||||
"text-radial-offset"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Icon layout properties",
|
||||
"type": "properties",
|
||||
"fields": [
|
||||
"icon-image",
|
||||
"icon-allow-overlap",
|
||||
"icon-ignore-placement",
|
||||
"icon-optional",
|
||||
@@ -143,11 +150,12 @@
|
||||
"icon-size",
|
||||
"icon-text-fit",
|
||||
"icon-text-fit-padding",
|
||||
"icon-image",
|
||||
"icon-rotate",
|
||||
"icon-padding",
|
||||
"icon-keep-upright",
|
||||
"icon-offset"
|
||||
"icon-offset",
|
||||
"icon-anchor",
|
||||
"icon-pitch-alignment"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -190,9 +198,43 @@
|
||||
"raster-brightness-max",
|
||||
"raster-saturation",
|
||||
"raster-contrast",
|
||||
"raster-fade-duration"
|
||||
"raster-fade-duration",
|
||||
"raster-resampling"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"hillshade": {
|
||||
"groups": [
|
||||
{
|
||||
"title": "Paint properties",
|
||||
"type": "properties",
|
||||
"fields": [
|
||||
"hillshade-illumination-direction",
|
||||
"hillshade-illumination-anchor",
|
||||
"hillshade-exaggeration",
|
||||
"hillshade-shadow-color",
|
||||
"hillshade-highlight-color",
|
||||
"hillshade-accent-color"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"heatmap": {
|
||||
"groups": [
|
||||
{
|
||||
"title": "Paint properties",
|
||||
"type": "properties",
|
||||
"fields": [
|
||||
"heatmap-radius",
|
||||
"heatmap-weight",
|
||||
"heatmap-intensity",
|
||||
"heatmap-opacity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"invalid": {
|
||||
"groups": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,68 @@
|
||||
[
|
||||
{
|
||||
"id": "osm-liberty",
|
||||
"title": "OSM Liberty",
|
||||
"url": "https://maputnik.github.io/osm-liberty/style.json",
|
||||
"thumbnail": "https://maputnik.github.io/thumbnails/osm-liberty.png"
|
||||
},
|
||||
{
|
||||
"id": "klokantech-basic",
|
||||
"title": "Klokantech Basic",
|
||||
"url": "https://rawgit.com/openmaptiles/klokantech-basic-gl-style/master/style.json",
|
||||
"url": "https://cdn.jsdelivr.net/gh/openmaptiles/klokantech-basic-gl-style@v1.9/style.json",
|
||||
"thumbnail": "https://maputnik.github.io/thumbnails/klokantech-basic.png"
|
||||
},
|
||||
{
|
||||
"id": "dark-matter",
|
||||
"title": "Dark Matter",
|
||||
"url": "https://rawgit.com/openmaptiles/dark-matter-gl-style/master/style.json",
|
||||
"url": "https://cdn.jsdelivr.net/gh/openmaptiles/dark-matter-gl-style@v1.8/style.json",
|
||||
"thumbnail": "https://maputnik.github.io/thumbnails/dark-matter.png"
|
||||
},
|
||||
{
|
||||
"id": "positron",
|
||||
"title": "Positron",
|
||||
"url": "https://rawgit.com/openmaptiles/positron-gl-style/master/style.json",
|
||||
"url": "https://cdn.jsdelivr.net/gh/openmaptiles/positron-gl-style@v1.8/style.json",
|
||||
"thumbnail": "https://maputnik.github.io/thumbnails/positron.png"
|
||||
},
|
||||
{
|
||||
"id": "osm-bright",
|
||||
"title": "OSM Bright",
|
||||
"url": "https://rawgit.com/openmaptiles/osm-bright-gl-style/master/style.json",
|
||||
"url": "https://cdn.jsdelivr.net/gh/openmaptiles/osm-bright-gl-style@v1.9/style.json",
|
||||
"thumbnail": "https://maputnik.github.io/thumbnails/osm-bright.png"
|
||||
},
|
||||
{
|
||||
"id": "osm-liberty",
|
||||
"title": "OSM Liberty",
|
||||
"url": "https://rawgit.com/lukasmartinelli/osm-liberty/gh-pages/style.json",
|
||||
"thumbnail": "https://cdn.rawgit.com/lukasmartinelli/osm-liberty/gh-pages/thumbnail.png"
|
||||
"id": "toner-gl-style",
|
||||
"title": "Toner",
|
||||
"url": "https://cdn.jsdelivr.net/gh/openmaptiles/toner-gl-style@dcb6e64/style.json",
|
||||
"thumbnail": "https://maputnik.github.io/thumbnails/toner.png"
|
||||
},
|
||||
{
|
||||
"id": "os-zoomstack-outdoor",
|
||||
"title": "Zoomstack Outdoor",
|
||||
"url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-outdoor/style.json",
|
||||
"thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-outdoor.png"
|
||||
},
|
||||
{
|
||||
"id": "os-zoomstack-road",
|
||||
"title": "Zoomstack Road",
|
||||
"url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-road/style.json",
|
||||
"thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-road.png"
|
||||
},
|
||||
{
|
||||
"id": "os-zoomstack-light",
|
||||
"title": "Zoomstack Light",
|
||||
"url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-light/style.json",
|
||||
"thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-light.png"
|
||||
},
|
||||
{
|
||||
"id": "os-zoomstack-night",
|
||||
"title": "Zoomstack Night",
|
||||
"url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-night/style.json",
|
||||
"thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-night.png"
|
||||
},
|
||||
{
|
||||
"id": "empty-style",
|
||||
"title": "Empty Style",
|
||||
"url": "https://rawgit.com/maputnik/editor/master/src/config/empty-style.json",
|
||||
"url": "https://cdn.jsdelivr.net/gh/maputnik/editor@9cf74ca405d2be0608b57db8109cf3a6af5b9f49/src/config/empty-style.json",
|
||||
"thumbnail": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAYAAAAECAQAAAAHDYbIAAAAEUlEQVR42mP8/58BDhiJ4wAA974H/U5Xe1oAAAAASUVORK5CYII="
|
||||
},
|
||||
{
|
||||
"id": "mapbox-satellite",
|
||||
"title": "Mapbox Satellite",
|
||||
"url": "https://rawgit.com/mapbox/mapbox-gl-styles/master/styles/satellite-v9.json",
|
||||
"thumbnail": "https://maputnik.github.io/thumbnails/mapbox-satellite.png"
|
||||
},
|
||||
{
|
||||
"id": "mapbox-bright",
|
||||
"title": "Mapbox Bright",
|
||||
"url": "https://rawgit.com/mapbox/mapbox-gl-styles/master/styles/bright-v9.json",
|
||||
"thumbnail": "https://maputnik.github.io/thumbnails/mapbox-bright.png"
|
||||
},
|
||||
{
|
||||
"id": "mapbox-basic",
|
||||
"title": "Mapbox Basic",
|
||||
"url": "https://rawgit.com/mapbox/mapbox-gl-styles/master/styles/basic-v9.json",
|
||||
"thumbnail": "https://maputnik.github.io/thumbnails/mapbox-basic.png"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
{
|
||||
"mapbox-streets": {
|
||||
"type": "vector",
|
||||
"url": "mapbox://mapbox.mapbox-streets-v7",
|
||||
"title": "Mapbox Streets"
|
||||
},
|
||||
"openmaptiles": {
|
||||
"type": "vector",
|
||||
"url": "https://free.tilehosting.com/data/v3.json?key={key}",
|
||||
"url": "https://api.maptiler.com/tiles/v3/tiles.json?key={key}",
|
||||
"title": "OpenMapTiles"
|
||||
},
|
||||
"thunderforest_transport": {
|
||||
"type": "vector",
|
||||
"url": "https://tile.thunderforest.com/thunderforest.transport-v1.json?apikey={key}",
|
||||
"title": "Thunderforest Transport (heavy)"
|
||||
},
|
||||
"thunderforest_outdoors": {
|
||||
"type": "vector",
|
||||
"url": "https://tile.thunderforest.com/thunderforest.outdoors-v1.json?apikey={key}",
|
||||
"title": "Thunderforest Outdoors (heavy)"
|
||||
},
|
||||
"open_zoomstack": {
|
||||
"type": "vector",
|
||||
"url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/data/vector/open-zoomstack/config.json",
|
||||
"title": "OS Open Zoomstack"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"mapbox": "pk.eyJ1IjoibW9yZ2Vua2FmZmVlIiwiYSI6ImNpeHJmNXNmZTAwNHIycXBid2NqdTJibjMifQ.Dv1-GDpTWi0NP6xW9Fct1w",
|
||||
"openmaptiles": "Og58UhhtiiTaLVlPtPgs"
|
||||
"openmaptiles": "KDhMfHvorAFkFe64wlZb",
|
||||
"thunderforest": "b71f7f0ba4064f5eb9e903859a9cf5c6"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IconContext } from "react-icons";
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
@@ -5,4 +6,12 @@ import './favicon.ico'
|
||||
import './styles/index.scss'
|
||||
import App from './components/App';
|
||||
|
||||
ReactDOM.render(<App/>, document.querySelector("#app"));
|
||||
ReactDOM.render(
|
||||
<IconContext.Provider value={{className: 'react-icons'}}>
|
||||
<App/>
|
||||
</IconContext.Provider>,
|
||||
document.querySelector("#app")
|
||||
);
|
||||
|
||||
// Hide the loader.
|
||||
document.querySelector(".loading").style.display = "none";
|
||||
|
||||
12
src/libs/accessibility.js
Normal file
12
src/libs/accessibility.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import throttle from 'lodash.throttle'
|
||||
|
||||
|
||||
// Throttle for 3 seconds so when a user enables it they don't have to refresh the page.
|
||||
const reducedMotionEnabled = throttle(() => {
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
}, 3000);
|
||||
|
||||
|
||||
export default {
|
||||
reducedMotionEnabled
|
||||
}
|
||||
@@ -1,33 +1,37 @@
|
||||
import request from 'request'
|
||||
import style from './style.js'
|
||||
import ReconnectingWebSocket from 'reconnecting-websocket'
|
||||
|
||||
const host = 'localhost'
|
||||
const port = '8000'
|
||||
const localUrl = `http://${host}:${port}`
|
||||
const websocketUrl = `ws://${host}:${port}/ws`
|
||||
|
||||
|
||||
export class ApiStyleStore {
|
||||
|
||||
constructor(opts) {
|
||||
this.onLocalStyleChange = opts.onLocalStyleChange || (() => {})
|
||||
const port = opts.port || '8000'
|
||||
const host = opts.host || 'localhost'
|
||||
this.localUrl = `http://${host}:${port}`
|
||||
this.websocketUrl = `ws://${host}:${port}/ws`
|
||||
this.init = this.init.bind(this)
|
||||
}
|
||||
|
||||
init(cb) {
|
||||
request(localUrl + '/styles', (error, response, body) => {
|
||||
if (!error && body && response.statusCode == 200) {
|
||||
const styleIds = JSON.parse(body)
|
||||
this.latestStyleId = styleIds[0]
|
||||
this.notifyLocalChanges()
|
||||
cb(null)
|
||||
} else {
|
||||
cb(new Error('Can not connect to style API'))
|
||||
}
|
||||
fetch(this.localUrl + '/styles', {
|
||||
mode: 'cors',
|
||||
})
|
||||
.then((response) => {
|
||||
return response.json();
|
||||
})
|
||||
.then((body) => {
|
||||
const styleIds = body;
|
||||
this.latestStyleId = styleIds[0]
|
||||
this.notifyLocalChanges()
|
||||
cb(null)
|
||||
})
|
||||
.catch(function(e) {
|
||||
cb(new Error('Can not connect to style API'))
|
||||
})
|
||||
}
|
||||
|
||||
notifyLocalChanges() {
|
||||
const connection = new ReconnectingWebSocket(websocketUrl)
|
||||
const connection = new ReconnectingWebSocket(this.websocketUrl)
|
||||
connection.onmessage = e => {
|
||||
if(!e.data) return
|
||||
console.log('Received style update from API')
|
||||
@@ -44,8 +48,14 @@ export class ApiStyleStore {
|
||||
|
||||
latestStyle(cb) {
|
||||
if(this.latestStyleId) {
|
||||
request(localUrl + '/styles/' + this.latestStyleId, (error, response, body) => {
|
||||
cb(style.ensureStyleValidity(JSON.parse(body)))
|
||||
fetch(this.localUrl + '/styles/' + this.latestStyleId, {
|
||||
mode: 'cors',
|
||||
})
|
||||
.then(function(response) {
|
||||
return response.json();
|
||||
})
|
||||
.then(function(body) {
|
||||
cb(style.ensureStyleValidity(body))
|
||||
})
|
||||
} else {
|
||||
throw new Error('No latest style available. You need to init the api backend first.')
|
||||
@@ -55,11 +65,15 @@ export class ApiStyleStore {
|
||||
// Save current style replacing previous version
|
||||
save(mapStyle) {
|
||||
const id = mapStyle.id
|
||||
request.put({
|
||||
url: localUrl + '/styles/' + id,
|
||||
json: true,
|
||||
body: mapStyle
|
||||
}, (error, response, body) => {
|
||||
fetch(this.localUrl + '/styles/' + id, {
|
||||
method: "PUT",
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify(mapStyle)
|
||||
})
|
||||
.catch(function(error) {
|
||||
if(error) console.error(error)
|
||||
})
|
||||
return mapStyle
|
||||
|
||||
44
src/libs/debug.js
Normal file
44
src/libs/debug.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import querystring from 'querystring'
|
||||
|
||||
|
||||
const debugStore = {};
|
||||
|
||||
function enabled() {
|
||||
const qs = querystring.parse(window.location.search.slice(1));
|
||||
if(qs.hasOwnProperty("debug")) {
|
||||
return !!qs.debug.match(/^(|1|true)$/);
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function genErr() {
|
||||
return new Error("Debug not enabled, enable by appending '?debug' to your query string");
|
||||
}
|
||||
|
||||
function set(namespace, key, value) {
|
||||
if(!enabled()) {
|
||||
throw genErr();
|
||||
}
|
||||
debugStore[namespace] = debugStore[namespace] || {};
|
||||
debugStore[namespace][key] = value;
|
||||
}
|
||||
|
||||
function get(namespace, key) {
|
||||
if(!enabled()) {
|
||||
throw genErr();
|
||||
}
|
||||
if(debugStore.hasOwnProperty(namespace)) {
|
||||
return debugStore[namespace][key];
|
||||
}
|
||||
}
|
||||
|
||||
const mod = {
|
||||
enabled,
|
||||
get,
|
||||
set
|
||||
}
|
||||
|
||||
window.debug = mod;
|
||||
export default mod;
|
||||
@@ -1,7 +1,7 @@
|
||||
import styleSpec from '@mapbox/mapbox-gl-style-spec/style-spec'
|
||||
import {diff} from '@mapbox/mapbox-gl-style-spec'
|
||||
|
||||
export function diffMessages(beforeStyle, afterStyle) {
|
||||
const changes = styleSpec.diff(beforeStyle, afterStyle)
|
||||
const changes = diff(beforeStyle, afterStyle)
|
||||
return changes.map(cmd => cmd.command + ' ' + cmd.args.join(' '))
|
||||
}
|
||||
|
||||
|
||||
22
src/libs/field-spec-additional.js
Normal file
22
src/libs/field-spec-additional.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const spec = {
|
||||
maputnik: {
|
||||
mapbox_access_token: {
|
||||
label: "Mapbox Access Token",
|
||||
doc: "Public access token for Mapbox services."
|
||||
},
|
||||
maptiler_access_token: {
|
||||
label: "MapTiler Access Token",
|
||||
doc: "Public access token for MapTiler Cloud."
|
||||
},
|
||||
thunderforest_access_token: {
|
||||
label: "Thunderforest Access Token",
|
||||
doc: "Public access token for Thunderforest services."
|
||||
},
|
||||
style_renderer: {
|
||||
label: "Style Renderer",
|
||||
doc: "Choose the default Maputnik renderer for this style.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default spec;
|
||||
@@ -1,6 +1,6 @@
|
||||
import styleSpec from '@mapbox/mapbox-gl-style-spec/style-spec'
|
||||
import {latest} from '@mapbox/mapbox-gl-style-spec'
|
||||
export const combiningFilterOps = ['all', 'any', 'none']
|
||||
export const setFilterOps = ['in', '!in']
|
||||
export const otherFilterOps = Object
|
||||
.keys(styleSpec.latest.filter_operator.values)
|
||||
.keys(latest.filter_operator.values)
|
||||
.filter(op => combiningFilterOps.indexOf(op) < 0)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user