Skip to content

Instantly share code, notes, and snippets.

@eddy-geek
Last active March 22, 2026 20:00
Show Gist options
  • Select an option

  • Save eddy-geek/ff8c4f3d74e1be6747baf3c91b41a50c to your computer and use it in GitHub Desktop.

Select an option

Save eddy-geek/ff8c4f3d74e1be6747baf3c91b41a50c to your computer and use it in GitHub Desktop.
Slope/Aspect map viewer & track editor — MapLibre GL JS
�PNG

IHDR��=�2bKGD�������+IDATx���yt\�}�?w�;@��I�XHQ&%R�(ɒEK�-���8��c7��$'��M���nݸv���=�nO�ƕ]�n۲d��J$-��H-$Ap�/�>��7�{� �"��f����sH̛{/f~�����M�RJ����ۄ?(�N�D� Tu��mP��� �I����̯ۘ~qO�E!�L�R�Γ���B�0�����
�I�q����Ŭ>�o�lO�٩\��s�-�W��K���@\��5 �����u$�'%��=1]����@�5P�v R'���!O�_}h[x��� �o�s ��~
rOv�g`�:."ħ����ᵮ[3��}��a!�/$�d�{iᗒ'�xg�+���t������H���D���Y���`U���9�A4~ ���k�B<��{�_|�7�ߟwJ�3�lP��MȻ���MWW��������J��� �v]�f`��l������u?nȡ���/c�A� ���j����,+�9�P�8��3�j������PhU��`�A�Q�YL_�����{{�T�` g'q������Ɖ�BK�s�lP�X4��'&)��AB��b�b,&���R���w�.�<��A�c��,&�=��q��FJ���/�10�B�,Bȁ|w�� +L@W��a`�%z,�X�jP:�Z@T��Y��"֘�o`Pd�,�cgPJX�x6(%�tà�0��]\ P� ) =z/�+Rd�s���C_�g�t��R�X�!�EEU�������Lm��P��`�ED������ZUG�g�nC�o�" ߮h�,Pۼ��G�GNuȃ��݈�J�H�=��\@l�c\���ۖ.*�ܯBÔ��FI�x'.��'1�L�Wҿ{��+�ǥ�{�
�#�E�����P��b^�]��¾����|��]ar�=y�a�`��E�����n�w�+T�_�RF��10\��G���F�nm�鱺��"",�xf�2�ȡ ��F��?�V0�U8�C�F�q�X��Z�p9
�����c������Ƈ?͡Y�}�UǾ*F]�̍��������[^S�����{q�_eG�f{W�XV�*3�?�ύ�>�c�<���w���#��sȏ���\�^zX�
]��\=G���m[���ϡܳ}+�_���g~Mxs/���C�6������� P��v��ƻ0�޾mXWxϷ��j!���5A�g���7t�eab���x@!2�������g�Ȅ��${n�����ģv?�)O}�w1z�N2'� ��v���3?MK}=ݛ[�F����K�{�f��L�GI �o<����!
��#D�>�j���y�JB���K)$p��N�q1D��ʠic���B���ӻ�o5�5��\x���O>ݸ�]�}��qT�$ �����1zj4F|1t�Wy��ӭ������]�kzϷ������-l�:�ء/q׉��д�w��1n
W��û������`8�m}.<�S %w �%������>�� Db*5������U�b�
�ȸJ̿zzvzs��ĥ�ѐT�[��۳j?�aώ^��
NT��F~!e�^W��qS�D��-au��x ��`���I�� ��w���jN�M��ʞ���Q�W��_т5&V�1�04rh@�Q��k�g���7ѤDJ�]��:u�,��Q���M�G�{6L0�1� @��E��^���h�����i[�:q�O�����-�׹�����:�>��1�>7 ���-���)$ ��$�u����d��Ko�%���;aդ'U���@������7����]��ۇӐ�uKĻ��dz�v���5?H��}���{kp�������晤�=M�p<6�BK) ���^���I�y��^<��;,{ϙfЉ�T[���6B����nb�0�~�������{h�hJ#�a�����93-�{���rg���:^;?�Q�+QU ei�xu��&��;����o��Z;�Ɔ^�Jù��FؽH������tmi�s}u5����g)�U�aN_į$|uEXx�a����/�`� �X�$���\�顲����~��mb��;{� �:��������KDb�~?bmB,���sQ[�
�Ra���A�'���q��'����.�F.���Uմ55g%u��-p���hl�k4������{%�xlH:  �2�#hwS՘��R�ǁ�6��Ń=��7�Ó\��Y��
�<<q�Ɗ
:��1�{ �� S�}�<�E�+�.��+)�=r�rI2뫫hkn"Y��qN]�����u� 3�Uv�����-��E�ˆ���p��bf�|��N�m�ǁ}nb���ٝ���n8�a��=ǜӕ��.%�����
��ڦ��������\��ݳ���U�D
�5�͕�:|j�����%uN��ǩ�o��[��0�Bu/5�:�vߙv��ʆʡ�Xų��N<�{�H"�}�����������������L�9^ف�>7u �Ҫ�P�P
���AfK���
Q%BY��� �XV���YJ�gq��żۍ7Ȩϐ�����-�L�=X2�xl��BUUQ\��^I���y��[>���Y�]�lt���V�T\~?6��Y��P$��> $t���]a!^V�\�8��r'���;E4]�"�m�X��V}����Թ����R�H4ʂdz��qU�IYj���V�D��1�+l��)���7D-5���C6F�nE�������F�͝{znxܯ��\��.\>_{x3G+;�?<K�k���7JB�7Dp�r�v��ڶf,�_�K�O,� 55lnh���`s��q:��J�BHXx����V ��4nK^�5)Y�{��l��TeB��� �{�����t#��܉��4��be/���ud6ݪ0(��B��'���9/�/������ Ǒ��Q$�HUSI}%wE�ƥ '�T�������[Jz�����$�T��J��E��γ㺵�)�.���?�+�Ƌ{UKI��!_�h(�k��yu� G+��!Q�H��C�1����u<JZ�}��'!���a�e~f�|����,�%��i�ƊW���c��`�p�SD��⅓h�<�g�E1�9V�N8�*`��+�{JvŊw�y�۔���F.=�\q���3Z+��率H��$�F��"(�`^bj�q��r��(�QLf.\8��<����EI��^�+/� {���o8�XG��>�Y�?���[��X������ ���#�g��(Nu^�@�C���Z������Qr
��9Ӟ'� u����"�Hy�����f�ة�R����%�W��p�е�u\L��D���a�X�f"��W^��k�KJj>���F��ѵ�=x���l܊W+������/�-�)�Zj�yw^�����"u6nEH�y����Ko���̷ZЋ�ɡ= Ը��  {�l�ȝWcz�u�����n�݅�xI�XѤ�3��$���8�K6�
ͱ��w���:���ÒP�E��h4�}�2!�� �������x�& ����f��)
��(��B �mμ��3� H'� )���ObU-<��k<e�?C1��#e��x�����b�^ؾt�+���O(��QH�� u֏�]�������������0��v_-��`p�Ȏ����QK��3/�N;��� }��2��C��3�8�y�4���>ΣS�,M9m[&��f�vz Z���Z�+��U#�� ���;�k���X-��_���ӌ4\��яs��:��b�sx�s;�4>9L`~�6��@saZy��q[��#/#i��!�&uk�6R�� ���-rj�i��,w���Ӽ�r���f>?���r��Q?�?�Ev���E�b%
�$�O��<x��Y��GfĪZy��]�}��Pϋ<���������1ISN�1a�$�Pu�T�c`�+��A��>�Pg��r��5w� v��3�\���W��\�����Uv�t���K��1R������#�-D�
=瞥��.J��G���x�Z�C<����bδ� �:�K�.����r7�x��ZޡM�Ÿ]|�]��Y��ak+�����kT�{,���H�}֑�U!��s���x�k7��6������^���)ʡ����������gkK7/t��*��s�"L���AZ+zy��6+uf��s9�x�ݭ��0r� ΂N�N�V�A���q^�8�R�皇�����c��>�^ڕ6~��<e�qA����.��ֺf4���T惢s9�s.T5����Y&�C��w�o���Z^o=��̗���n�?�8�<D���?��$�����N����׹�W_)(��$K�R�9��^��O`F�OO}�������9��d��D��l��<��<f������pCV�A����9ɦ�Sy��륨�as���G#�-���h2�ӆ?�<�E��J��l�t�6�繭��E�����Y��"L�jm&V�>�˂9C�h\�Is�R�9���R:�ͻ�ٚ9���V��Վ���c�ן�:���SU,�yj�Sy�A)���!�$���+ gC��7
)���#��q�Dƞ=~��"�I��3�LZ3�Of�����t>@�yk��E����'!���{\��lg��S���t���pq�� ��z��eg��as-��Fp�ly���B��I�ˏ ��:3��:�ie���9�+�r�α-��/[��4G�?'q%RJ�Ǟ�������&A�����uek�W�m�[iSn>[%U��h�o:�Ϛ���QS��:c�,<:�pVꬔ�N���k����k�|�� ��Wtowʱ�:o����?�ϯ�)��}ڔ[����z8�N�tm'Ӫ#�}�&^�4+t�}6��G��ж)���F0���]��{t�aL� {��A_?~�si�#3��ڏ�u�.�o?2�0V-����G�s����y���ޗ# �u�? i�yv]u�л�����������⡹��3��g�n�Wڎ0[m[��\�t�q����{�o�G��Q}�0�\�f67tѱu_�z�:=�cvz!����$�l�1���AT�����0U=��~�ؽ�m����ع����ؖ׸�}�`���2wF��jmF�B|��`SK{�z��?�—����b�����d]�����)B�������n�.N����� 7<�+�s�y��� ���.nc����]8˝,��{N�^���۹��� �j=�f��@*���=�h 7����K,��>͏�ԝHju|�
V�mS �eN: �����U�j>`���%ı��oy�z���t�bӭDž�a�t�a���A_?���fT�@���g��B3Yp�ݗ��&GA��X �� �����;��\��H�1B���p��O�� S53������O��>��M� ټA��47+t���(H��6�$�j��s u�j�N�^�N���N�/q�Ѻq�w�yz�O����9Ҭ�M��o"�ܿ����e��Ɨ�k'u���[��i�< sO;ϯ���m��:_N{��ѺqF���
v�X�l���~�x��皆p���W`hi&��Mf��􌱂�=?� ���@r��J��Z�X�mNW�d�*d��)�K�`Q-|l�#�w���/�M�[�6�|hMӘ�s���zꜰ�>�jRy����=ӗ �ÌՎ����N�`Fu����x�0֠G�U-�C;<���������i7r��m�����֓�t�H����Ǩ��g\��K/qۏ�M��������^н��Թ*^̓��6ݖ�t�Y~pT�x��%*��|j� ̘3�O� �i痠��Q0
�v�P!���`؅7��:�jӕo���\�y:��<<�PFu��x�|���y�]g�v����m:G��7[R�銝C�/��tq��n�i�s��FBT��rG��~o�7�k4+!�����لM�b�Kh�0��ET����_q>1�;4F�ϧ��𥲭��v�
]LM�=#-L��V�A���ٳ��{��:_�"^�'��ϧaf^�����w���bE Fp�|�)�Jxmu^m6�F%��td�����GN�,��>zb†N����r��l��Ms��S�O���� �ġ�hW:��~7���\n��V=���T�O���8�:5���ۙ��_���"�<�F�Q� n]� E<���Nq��M���V>}�񨞽L������� �����*��‰ �ҎD�%S���ѦK�l���b4s��~w���g�%o.G<cnv�86��r���٦K�l�Ӈ�Z�`�FF#��6]x�Ů۲WyS��i;qU��w^����6]�d�O+���V�q���J��]b��?�Z����RӘ��g��H%|��9R��`��I.7^ݰ6]2\ϧU�/�4�r�CK$�)�QL|O11���=w -Y����S3v�1}�Ϭ�ζ;B���Ln��-X����(�W:p��wv���ꂦX��v�T����ޣ�k� �]�;C\w�CJ����D�ZC��k��'k#����W���g�[���T,�)FOf���7���܄CQ]�\/wn;WA��B������7]�������u\��⩭�o�v��E���B�d���uշƗ&!酲�� P�6�}�R�����moK�z�Drc�.kaw(@��A@6bQW�ñ�!����;�Ψ��>����e>��(�O�0�������Y:C\כ��q}��V"�먳�>xLV&,U|4f�c�^�6s����pi�<��1:*���R��|/��|T��x9��E=�Zfd���.�2ЗJ�q0�䑘���yy6S9_�vj�w0������M���΢g�l�sa&��r���'"6�0:�Us5QU�☤��I���ﶤ��J0������̺��}�A�(�̏*:�^�6��e���O� ������rS8r-�ײ^Ǣ#n���������oVo�-,TG�‚)X��� �
 G�ͻ���Ϭ낡�E�C����>.Y�Q���x
������yTU�����P�"$ ,|�b;��"��5Lj8�x˩Bǣ1�'!͹ u.V�&+?���n�;���i��9͡G'����\6q���EϘ���&)sM���3)??g
�i� ��Ά:?!a♊6����ÔU:g
=>9O83rg��9li�),�C>.���ANZ�c��k��\*(��-��h�j&Ϧ�r�pu�efމ�D�k³5|�R㼵���r*F�` {����LU�a:�ZrP�����io�խ�b�����������G�����ݨ� I=G��nj�i��DhZ��,�\h��A0�S�΂ج�� [X�!�%���%E��l`��B��B�õ3(%,4��à4�, �&�=10��?F@��&�S�A6Ф�������|w�� S�WMh����d)x�"M�#&i�'bP�����R�+nm ؚ���dt�ż�$��&xZ��F1J֋�iXP�̦��8�IL5�~����[}Z >��.���O�7�?+��-f����u�� =˜M����z{�1I����BF1J*E�o�7���R+��zy؋�A��6���>!�3 ��+F�_���N׮��O�ľ�1���74�5�|�o�r]�f`�Q���� ^~��4��G>�'AmPXD$��;ų�=xˀ��!�3��Aa����kGou�� 0��}B���q�h�_��L�S;�ŵ�.Z7�!�~��� F
b�'���|3��7W���<���3|EH�H�|����#���&��zV����R@_g�#�O"� `�ck�MF��H�S��b2�'��+�X�;�8�d0t�X�X�i�%� �Dp�[R���:q9�J�?<��<C�Q�IEND�B`�
�PNG

IHDR��abKGD�������GIDAT8����JQ��sg2e MGC���6p�&�D��E���%�^ wm�h=@. ZT�rS�)�4��-4JFG�_�s����p��{��D״�g:5U5E���f���MŤ�.���޲�4[�@�� `mgY�F��ZNS��W*�;uf�:g�*xぃ�#*Q������,A��
��x�q�O������d
큚p����x@��Ƙ���.�O�ȩ�h@(���u�;���`�z� 1[l�~��|��8�-��5� b�$l�����y�0��'�-~"�����67 I�[��h�c#:��'Czf�Y!�ڇL��h��EnS��(�IEND�B`�
�PNG

IHDR szz�bKGD��������IDATX�ŗ�kQ���I'M�д�M۴�-h�B,�@]��]���p+��v'�E7�U��T�������Zk���־L�L�E��ͣ3�����{����Μ3#��l�J�*pP^,qK�����X& ԕ�x-fdVu���+M�JkP�d���I����r�v�i4�%�ߐ���\�{NO�T��8x����w ?��
ؿ{ѽ����d� )�� �N�B`�]&x�<�А�Q����"t4�.gPt���0�p��U�(ͦ�P���t�n� =��Zp�Os+��Hr�����^o�=�I��ʤ張�P��nZ��1x���u����H" ���<L�t��qJ�p�@:v��oQM�#������m?ORM��u�@S�O�:{l#�V�fsNO���ȁ@]�@��N��6�s�;�6�u%;p@�m?�XB0��[���H�����)|5œ��>�3�C#���� ��F�59�wO5A-� }�z���]W@8�H���Y��`4b֣S/ Q�!T�MCKi���̨ͦ���`�=h0�(Oi#�7L��<6���\��Ty(�r�ŸIs65�ҳ<J5�:��<��Z�[�kU��1����DN<�'-�-��1�B1�akN,�N'-�-�RLj�-RCzULc�_��7Z�����Os���$/��kn��CiI{��������L �'dVu���
�#컚�w����� ��� ���xIEND�B`�
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
�PNG

IHDR��R�lbKGD��������IDATx���ytdWy���T�J�Zj�"����R�no��n���3��0���`B��@�C2g�� k� ��`2� 6�`�x7�n��H��%��Z��TR���v��zo�J��^�t:���z��OU�}�����{,�+�L�5K�w �܌0;Q��@�J�hC�jрY �C(�^o;u��_�n�g[����a_��Y�|D�| ؕ� �2�� S+��/�\ZN?_>� t�9� �b9�J$G�{��|�o�k���ƴ��%�ΩO ��g~h#�s�W�=���֭�tnH���ѩ��< \��tI�8b(|� ׭�_��%��
�_QĿ ��%�Ō����_���b9��+��������_R�T �O�|h��/v�-�W�L}
�|�z�$��"����y��.�|�h���OΜJ&��C|�s�7>s��<�88�a8�꼈&���Y��M����{��<_�n�~���HV"�����?�K�޸� �h����_���~�$�?����Ƒ�z}@e�ŒH�ǬC��|��o�Gv~���c���� �_N�TUE��HVq����O��C���>�����C���p8�S�%�ՃP��-��L�P�� ��I"���r��{���KV
��]!��vK"�؂�-.E��n9$;��t�-�Db����nA$;��u)r��d�"D� ��rH$������b],��t\�HV3�HV5��0�f8M�h�z�E)
�XAtu?����kn���ݢ 2
��P+k(sš�~�N�h�Y.K!-�
��5j��@i����c��(9��Vs�a��~��~��S�)��(ON�,��[���%��?����m�t80Zw�������r�d��R�!�
x���;�ξv]}�fCSm��8�C�"��5\3S�4�p���mi�����I�#7|�&�
�7S#}���z��;JU��>��J|�wX�e�t��Ca� �����i���~k�����-ٌ̬��\s�i�e-�"}�"fr���-k�,/�������U&h
�^��Վ��C�����{���5u;oe��\.7��D�֗"-@����堳uâ�M�� �Ys3�ڶE�[��T�"d^��r[WN��:l玫�ŁN����j�"u��ȉ�"�?܋���q{ ��������>���N��o�8��2"���Y15 ��I�5ֳ������E���� �A*B�\}TF��/����Hb$�:|<���{е��]mx�q'���0�OS�d%,.�H���i�P�VLM�7܃ӡpö-W�Q/��tP��VF�u��ʎ㿰��,��J�[\�����L�8J��I�;܃��\�ކ��l�������3�;�K�2u�ù�ȐQ�4P�Q����1*�5�]CS� u��:�f|���z�����`��i66Y/c1"}�%����}�O�b�z�����k*��2��6.���gb�3T�NY)bQ"�%H�����! ��UCS��F �~�����l7l���P�⾧>ˮ�=f��Ňt�)&�� ��?-�̛�����M۷-��^Oټ��;ܸ��4�h
���{��H ���4¼��4MA*2� M�;ԃ�56���!��n��Ơ��aw=��bǑսvإ��:�Ř�dx���N*<CYC-�2�#�2q�MK��7[��Z�����g+��}Ԅ���N��N�X.���h t㊟ � ���!w�� Me|������m�D>/�����m[y�h�g���\sMU�����.[5�<���%��J��S������Jey�"?e�����,�D��t-e�wnW�>�eP�q U[�:C�IEgs"���L �`�q�ݹݲ��56��%��"��=�[V1!�\�d(��,kj*By���쌝:�zf��)/�kӥ�~�᦮m��1E���o� ��Jb�mL��ai;������h u���Bђjl�RtMel����7nߊ�ᴴ��m��prV2��F�Rt]}!�
q��4�~'�`�Қ
�d<��&Ͼ���IF�?i0?�����a^������'�Y8Ip� !���C�'������K��%qU\~qz&�k�c,t�ƚ��Ϻ�󙞍381�� �jـ����P�>�y�ME��d��M-Y�0:pU;���b��`�}��y����ى���斝�X�^!#�D�AO��f�?�O��Q��p�-?UZ�TN_��k��l�#�|����Kj�8K��8Ζ7Y�^ ��3ij�����fu���ѳqt���牧QL��@_?��#$�]\�������i���}��0��";M���`6��tg>��4���#� ��5��IU孞"33K^�@�8��$�z���/X�~!��}*���TS�Y+ZS��C�e�02x�L��|m]m�YJ3_��Y^=r��L,��S�MKQ��1p���&�eէB� �.�?�!2L��5���#�3� �����|*ȫ��O��ֽT(�甫MM����l�}r]V����</�-�if� ��FS/��Y �cc�s��4���y���qQ���=��Q�j@�&s���q�x0JUSz�Қ�24p���MM-��5,7�c�&�����\����:����֤�2�
�Umf��z�c�K|� JoX5�x>�s����e�/��ʰ�ǫG�d��( �BK%q'�v���Uk�̅r��kv*�gM �-8�4���Cg�OkjkYSS��������B��a���˴Wfc<�M?�<��ky�������`�.�� ��RK�</]�IDf�����5C��.������r76M�`l_(�Dp�D*�;�90���R�M�8Ih���%����f���t� D���|,_�TN�:�z^�_z�cJ�����!�0�q�UjV3PRǩ�:������ۼ��/Ve2\r:��L.}a����X����K>�;p��?�nh"���M&���Ė����� �0ݰ�Fi�gU��@(m�CT\�hZ�S}����U������>�� ��Mxp��r�f��C]æ7^Xu O���<�y���&H��V�[�;�{��������&S���r3%^/�IJ�٧|�� ����<��d�a�:����� ��?�FӒ �����n|���-�e��!��L������>9=��H�./c����Y���J�|��o�R^�VVM�ee�^UC��/<f̳��j����{�e֩�(!�(�AM�q��+�X-��t������̅c�hj����[���� j�4�zV�C�jR!�����W3T��Y�v�����a�UER��D�r�-��Ua � ��j�l �O���7����*�`�˹��n�9'�Z\�R��&#�����05|�SE9���� ��=�5��i�8Y��-�0M����x�Tm��j&��C��D�n$�R�9B+>
4=�Ԭ[��)��� .��V�|8�͆#��p��xs�V�0� ����|�~ �pR��Ųut͞���K���p�F�Vth64���.Ϲ�04��S���K�]5 :�P�$���nq�͊��C�<|����x�|=���L7w�0MD�O��+6(�!����4 �@xж��A����Y}��&�u_����}A���*��~4}�j��8��~���ݔ�K��۸��9J�̑�I�f� C����2>�Kmj��_|� �(���V���!o��\oxͰ���7uF�6o/Ou��D�("��v�S)f�3i��fu�u �;�u8��*��n�����lD��>�IYqNp�;i����C��q�:� ۣ���;�lه��Ÿ_��gk��j/���D�dvxE��� ��6�6 _�ߖ�7ʹ��?H�n�l���3�Ŝ���ˮD����\ 護Q�v{�����]]��w��}>3��C7T�?�Qx���xy��L�O�9��Ot��� y���xj2DR-������A$��}C��C��w���4'�������x|��k>H�V�G��N�m ȋ,)5Iw��� ��'�)+�}!L��}>�-�>t]�{�n����c"��ƗB���†�x��94E����y��a<je��)7 �}��\����vg�C��a0M�y���b ����ۨ�<i<�D���ώ�w�؎��)��'z�}�-���)Ta�R��ADhE�� �k��2�b���39?�U�uj5�7��R�����^* �x���|�J����� wyo?�3Ln��ei �T�*u�n���b��-�0A'����7zN��o[�b��������ލ/���g��D�;�����h�n�h}N��JRj��Ӈm�#���� �#�R�E&�'m��i�ie[t+Ѳo�ٟ�r�cu'���򁡇�k�������2T=l�|?-]� %.v��J ���z���o*�P������߬]�Ӿ7T������ �zp�a��ށ"���#�
��n�u�i{?Y�/3�ɸ};-xC}h6��_���3aϓ���w&h�Ư7����˃���n���7��M?g�$n��'���e���X�6K붂�v��Syv;��P��O���R�͞������"0�]ױ��<��}�����D�R�UZ*�����ʷi������r�h�@s�Y��YK�L�7tMW��lw��v*�J��[Ⱥ�)w�Ƕ�?�뻩V��w�ݖ����~>"�;I����~sq)ډ0�x��-�P��{��CW��p��.RN�WֽjY��C�筿"��ag�j�g�9���J��������������x�X��a�\� �����/��݋�t�F˛��=���Pya�K<0�^�:�������`����������'��w̷^�����<���6:��)����wr�Ɖ�n���iJ�a��.���uz������� �UX*��R�`��7]
;������ֽ�Q�3S�����`��n��*�� *n�5���]h�b*��) S �����>����kiN43���6��SeA�5���ps��K�VRs(/|���n{?Z(Ee M'��־/����Sk�:r.��4K�Qƻ�w#����i�9��� b���\M�l�e�n2績�e�N��[���A #��r膊/|r��8��������L���=ucV�]�saO_E`��d��PyaË(B����Ⱘ��:=�9+H����%ufK�Xa��}�\}K�MS���Is���+N�������� ԧ�Ά=_^��2%_'j{��9���I����ͤR <�ނ�Ec&�A U�n�&��면���t�.��,��:��۝��цcx�J��??���1y%fIZ��}��� {�Z�L�\8ĽNǜ�Z�E�~�%ufCQXa��'�6���������V�ᆣx+|L�y��Y����q���f9�����'�d�!aϥȕC���v�ш+.��VQ��"$S�-m릊/�t��^����Yږ�.�,�!�aϥXp�w���}�ͲzSj�C���=��o�Hޱ);F>�~�?�Y�t�|�=�"W3�%������3T{{,�s9�(�&O� �^ #�? ��>�)���ƒ�����S�K�Q���{���?����y{.E.f�5��8jr���?������ֶ/ܷ��w����3�Sֽ��p%P>ɏ��w:����*������{�3�9�wQ�y,��9w3�T�J�mV��P���qb�s����3�_{����v�H��8�A���!X��<�=�b�,��M�)5Jy�Ļ-������QA*����E[�XA� :6Z��������/f�f�|��lz:�zr�kk����U� _��ƣ {Ng]�ӥk9��\u磸m���$�I¡X.��+�jZc��Þ+�\8ă�J^tTq��>[�Y��c� [�ק�Ά=_�(칒9�!���e�8�:T�,�3
��)��dĖY/]OO��7�n������֖YZ;�eۯI9S��òd��:3����/Z����c����=��s;<_���6:��Dʢ�kڟ'�
��;̳m��a:xx���X�vyH����z�*8 ��~_;���҈��,������P�*5�y_���2E�����@��Bx'�0 {������ i��n~�q/���3���w[���� �!�N5���VP@�&�S��� ���^W�m����s~O� ��fi�DWt~��3R�{�w�>�)��z��Z����{0K+�d
���jϐŸF�g��=�!\:�(B�#����wy�� ������_�����Mb���������l�Վ�����y����������!yS���='����Kj��l�W6�b��<+�_m�˺������-�]v]���9�� �[:����L��|�M7Z(u� �pzԞ!�n������8�б�X�M.9���������E�����Qs��uB�ؙ��T��x–ї?����a����x�݌H��Вxo}���|�Tu ��[��
� ۔��*�%"?2��V��N#�rt�A鴟��AJ1��K��= :�e::��2-��R��\V�|����� ���$]O~���� Y��l? ch�kK��M�@���(�[÷�Ѫ8�xD�=3`��T���S���׌c(JF�h8�'w+�;���-W�x5‘ގ颼�M٦�f�I~��q��&�'����g)����I�2��߹��wXF~���������^�KJ��� ��2��<hFy��d���ev�U��hk*���w~X��Y�ŵ�UC-�?vu�Mo-���k� y�Sk��FӸg2�q&Й
�OOҲ�'��AۖD�R~_ؖ�KD~J��7gJ�adf�%����%�Fٙj�r�*��TM�X�l���}�"�C�t��5�% �Ǥ��Fh+CQ�����3�W�����eճ�vxg*A��g,�2���6q���PԎ�AŊ���[�2���8� ��yi3^����E�d�-�#�[vyNG�K��-L�<s���;ŭZ����N?r�����.�K��49=�dK����K�E�w7Qgj�ar����s�����} 3<AYxš����yU�C#~N���g�����g1�P4tqާ�٢��:K�~i+��9>����v-C��Ŭ�ȫ0M���- ^��>����^g%_.�·�6Q&L�zn�vsmn�
�I<�Y���T�/D2��c2 Scrz�Y_IAs���/Ul�@I �h E�L�Ѽ��Y���Y��ɱ�dY�)N/k婲������4��%�����[4�03��en�c���W /�41��O'G�9�0*jɦ�mI���ז����~9�_a�+�����4�zi�r٤+��e����?�Fj����% 4�z��d����W˪'/>���51�L D��_�t�R������T�z{Yu�<
���o�>�����gT)�HI�4���?f�opb�S��@�Ô��󔻅8NJ��Ԟ|5��sj������T�A��W��W�)N�N��q�� 1C^�i��6.G`�Oj�Uķ��1#��gj�,�C�sfTUcd4�����L��H�����5�H%f��{3C �#��T:�[rU� MP�6��#��r�J�]dn'�c��T����Ȥ.�;IV-�7�Db�3�DbyI��H
i$��HV5�HV5�HV5�HV52
$Yո@Q��d5�r)0 ��-�D�w3 b��-(D���[���2�R� �g�,I�Q��@�[N/�=�-�x@4��Ly_�(�؊�tG���f%����H$�DA컦Y �ByB(�d�PI�P�'�L*��v�;�U �$�M��I8�(��Z �����(��W.�$�(����j%�%� ���
HV>3.����D~���@����$��E(��hP�����t��:�λTI~8�������������꼉%���Y���ڼF����K�t�(�(ʣ��/�$�cEy������F�'⻹�K"�9B(�'��*�]��E�#�Q�=r���D�C �(�Q�\Q�/��3)���9-��OLA���:��]�Vܩi�U1y���$�"��p���ep�k��bK�2p���
|��q�Iz� ���ZnM���2R�G��E�|�'�ʌE�H�'.��8�j�G�gr�����D�*��"�(�;��$�e �}����k�eFgI��MN�=�[��h���+��P�߹d
VzM�ۆ�W:���l+��
N���w�IEND�B`�
�PNG

IHDR�x��bKGD������� IDATx���wx\�}'���3��D!�$XĪ�"��V�,���-r\�����y�lgo�Iv�N�8�}�d�-o���؊[V�:EQ�D��7$��{�s��� �A�ι�����<6.���s����S�0B��G�녪nW�P��Z�!ڠ(mjTh�+I����(JP�
� �BQ�������+�E��5�";����'����~�P���N�(h�9�@ �r�C��B9�՝#��Z
�u��믭in��Àx�f�1Q����)t����蛿�iSFvH a��޳��>�<��ix
@�예�����Y(�3������n%';��X��gǽ���/
(_�);""�� (�WT���mge3Kv�~�{�p)_U>
��HDD48(���;۟��"B��\��׏>�(��!;"""����W�h��U:��|���À��vɎ����DG �����Ηe"���'}[����Q�q��ky���{�YH��=2R_�V�1�:�����$�)�_�S齿y��T�߼��o�?�+��6������,芮*��no��oZ���}��u- {�(� @-��ـ��R��o�ݽ*Y�7,K�G�n�U��\��#""�%�����r�`z�ώ�>#���w���J��k_����f��i�g���M�?W�|Ŭ� ""r,E�������WQtS^ތ�{VT�e���@yڌ�'""�?J�c�������/lx`�Yoc}V�!�Ǎ~m""�
��6�|����E�|QC;���XgU��"�4�u���*�����}��o_�5�% �_��ֹ�U7��DDD4�r���Î�W�x1C:r���V��b��ьz������V�.��������3�b��DDD� N��~�7v,/�U�t"�޳�:]#~��?Q�(������~�r�R^e���B����w��K ����F��˿��3µ��Xt%��S������}>-�|l`s�O|u��_�o�}����<���� # �������B����7NoC^@�B�KDDDSPUeǿ��mpaO[���������ܺ������L����~u��+� Z�i�x�'""������/� %g�q����╅<�����F��x�7wv�/��%e��W� x�'""�*EW����QUʃK�4���
6.-."""2������R8���/����]8 ��ɣI���������zܼ�.�M
o�DDD�P�*�?0�a}sf��T�Ê��hhXDDDd>�����_��۳�B(����9Q���h�o���˓�������������I��}s���̉�����A�k�}o�5y���P��y!Q9�w}eG��7^�q�p)��������ly(���7^/�|��wS^u]��{DDDd;BS]�~��e�S/������?�S(n]�r�ũ_|���JT�,[XDDDd�ц\�ک傧eU�'��?�ӬLT�za�"@EZ��9����4����~omK�y �R#"""3�#��ν7+Y`�@s����9ֲ�|���/�����`����L�<1��';
;DDD��+���
|���[Q�!y!QY��U���������!""�2�R������{�FCDDDe!pp} ��C!""�rQp�{�P�*9"""*�E�(�<��Iͻ.��������Vש���dBDDD����v�[�� ��ODDT1T�u�J��@����|tU�W����"УX!;"""*#�t�
�&;"""*#E���r Q�P�6@��@����|P����U ;DDD��� (.�QQY���CDDDN��""��� Qr�)""��� Qb�����@DDT�� ����1@DDT�� ""�@�U v���*;DDD���ȱ��^�W��Ȓ� "���]�Co} O���hL�e�CdIn���w ;N�+��+�����/⽻~ArdD�� 9���?�_�:V�.�������!��5�@D�pm�]�CA�ۅj� B�=��Ͼ 9:"�q+\HD�ll�`�v,��"����o�� �2C$�f��!�{~�U��׸U��x@�����2�#�v��⃽X��fh�<�r��k���o�;�DS�@D������.UEv�S.(�v1 @4v�������s����>�������N>�Y��� "[K}hV4t�-��6�p�.� � ��&"[�����n�vm" Pu�M�TW#�͢�U�|��@Q�s�G@D���
p�M}/��)��@M��tW۶���� ��#!R"��Q�Dd[�޳زv�j��W�<%�kb-N�[��7�I��-Ń^ă^캳x�?!��/���W����z�����D�My{ϡ��[�v�����@[�v�����se����� "ۙ�߱i\��c.U���M>��� ��l�{�, vm�X�svmވ����{�,���43Dd+����+ۖ��}��O�վ+ۖ#6% ��<:|��
���� "[�r�kK��� ��^9�u}����~��gh���T��<���bbߥ�رi����c�Z ��#<0�V��P]��P��=�������Y3Dd���غv�j���#����Q�j�i5�����x��H���"���+����܎�P][�8��<��P�����3&P"��."����h��Ŗ���zs;ܷ?��sЅ��ˢz|`}��8��H4�3��1@D�7��_�c�z�\�����}
U5���o�l;����;Ddy#�O��&5&^�h-wP�`��,m�����V�jo]�k�<�i��*��ט���YZa�_���7қ��x����P%�"@"��XЋx�0�/���`�k7�s�/�U�'���]�p�y���p��ey.]����ӽ�����]э��['�ֲ�,���A�v�a�CdUn��$C486���-7�ʃ�D�{����������~���ߋ�j� "K�tf��7��b�"���eö[�β-H�uj��{ ���O�SDd9��bSF�;7mX���ٸ\*B�|�5ߌ�Z]��DN�Y�Ȕ�@��&�׾�^��&r>� "K�q������L{�U�mX�ފp����14�v���DV�r�Dd)×����X�w�][nˆ��i1l��9���2��1Dc� yUU�c�y�� ;6m�����;�db�<jz D��SDd#7�����F��{�g�XW��=�q�`�Z�����9�M^�;��O��:+��p YB40��tڵݛoBaLn�ݛo�پk�׳ؓ��=�^4WWA/��� 8z�� Q9p
��,a���i_7��b[��{�g��g5�j�h��]@�����L~]��'e���l�@D�M�s��s�FS�����R�s�F��+1t�z�z�=�mg��1f�!� "��o���������=îz�_3ymj`}�V
$�`�h�T�Tf�ҒDc�F!���վ��[�˪�Vt�/ǛM���x���"��r慲�Edv�f��G� �e��xC�N]۽e��H��7�T � �A>���LBK��gr��q����]�������?���7¥�����@4��?!T� ��DZf�o�YS����i��Ŷ�5���q� ��A�&&��Ң ��eP�xl����QD�E�w�������r��_śM��#9Ĵ��c���#�l��d�L���Q�A9�� +�'F߲Md!
Y����'�Ɇv:�wT���C�)D^��]φc�n[��>���ѢS�`Ǧ��R�r�7+��bǦ�p����8�AT�ꖏ³�)�j�� ����)������#��j:��?(�|r�{����V��h��Mx���\��߸aw�׭Ac� ���O�r�Dt�l�x�?!������,�RF���������V�����nkŪ�V��x�i����߃h`��+%FG�t\H4.�A���~_�uh�8�[���� ΰ�����[7�ٷ����t��?!!""�p(CL���� rK�RD����0�_��o�Eu;7��I��l?���@ M@��>������Ee�șf�o�Y��Zy{�g�X;����~"�`�@&P�6� �^�¨ك�X’�� �؊c��=�Y����_�//H�E�.�x�xz&[�����X��z�r��K�g\�_���v��Xö��h��E<�.�^��><Zǚ��B���f$�%DI�p�P����3 ��li� s͙�6�n3�O���������`�巠�:T��f� �.�hZ2 -����Z2�|2w}� Q9�����~�έ�g��Uܹu3�:yf��{��ؑP8�UWH���=�˷|�Y��v��� c����Ƶ���G�y!�ڪ�Vt���q^�mmX��:�����W�+�5��+2 @v�U�|�0��X�X���T�.��{v�O�k�U�cu�'��Z)�����H��U��/ ���0�{��5��RU��l���7ڵyӴ�
�71 @��U$�i�E���?�C�� �ș�]<6�$ʶuk�Xg����i��Ŷuk'���p�n�����ݰ@)埥�� !� � ��y��Q��8-�N�� 7�<5 �g�lƭ�U�ב � ��� EQ׶���o0p�f��k�����ֳ�u5�g�]�8V�w&L���{�nyI� ��'U�L0�Xp^ȔPG�����g��7a��Mp[x��l�3�[��x�Y���#Q�X�*�.�
Fg�.Z:E KO�k�?ܹuK�"1ޝ[�`��ӓ_O��1 ��܊� ����dU<
�*J*����,���� �P��b�k��|����6t�[��l&��_/���&�L �%t���q��/ ��A�Q͏�BdMܶ��GQ��R����B̑bٽu�� ��۽u3��>4�u�U��6o��Մ��6,�Gq����J�|%U�l8a��=��!����v�{�yf=@ѥ��m����ٽy\�:�g;X�}�m�¾�� DV�ZT12���#ϔ?��e�&��u]=t��o_� uue��< uuؾn-N�]��1W�Ŏ�,_PD � U�l4-m����t�%/lg����]6^�w��~�R~D2�@!�/�6���2���?���'M����O2��g.�N���r�\" -�.��%��~VP�hwזMp��2Ed>��®-s�g�����C'��܋e��h~�@����D����Գ���+K��#󮯰����ܹu �<qj�Lj��᡼��� �k����d \H���䐋%P��X�㥂�5�e}_B%����������5�/.��-��c�������p���yl9�N����h���9Z��
�@�[�B���s̓wm�_��R~���y[fݹ�ړ\�����޷Q�J�d��c�9 J�.V&�@�᥂C~B���]��]���صy3\3�F�F��B�)��C���ylb��vȱ�~���,���~�N����n�����{�g�XW���z���]u8Z�=���,@O�fH:vȑ���tP~��t(]�e�a�RF������f���Y�2vȑҁ0����\ �w�}!�l �������ؽm_׃��ڢ�?���hm���� +q+�@N� ��QX��v:E}�2G�
�G������[f�w��b���x��ɢ��ް���p ��=�A�����8y��0� J#е<d�
'����9k-@���N��k�V�#���~ְ�n< P0}G�$"GB i��wI_�m-PP,���{K . ���P��K���Wg�� �+5 ���N3 @r0@��ǡ笷�N�iȄ�Q*�������m=k��XNٜ��g΢wx��.B�Z�[5��\6 ��0��>\��#R�&b��ʇ�$}!�.o�ƒ,d����R��䊧Rx��yĒ�W�,������>�kp��� Q͌r�L$�|�������䐉&Q�\/;�E+��緭�9U�f� ���� �jZI�������q�z9��:,���nvH"� �HJ(��P)_�6��#�8�oki��8��]��+����~ʩ���lJk��9�Ad� G(w��Ųs��޳������/�hB�8�׋��/C��_��;%��]�*iDfH�B�qƽ!,[g�Q_�?\��ߩs�����.\�7l\�i"����=��� � ٞ��"��UdbIhi��U�I���K�n[�V� ݔ�H����)xCa�_��uDFc�l/� �a�ޭp��oA����s������'JZ���_W.� �^ƶ�?0�}�n�]dkzNC:b����H ���᪮�ʼz�U����.�[7t4�G<�FM2��pm�CH6v���Dw��%�!X���B ��?��U����������F�����qex���Rܗģ�>4�YT�5B�8���8��K��7�ȶ�|�`T���b[2�|����J[������H�'��p���o��R��I/��N������}��?U6vȶ��t�˾�/���n�� 2�����<�*,�3p��\�hX���ǰ�א�R�<n:�|Yb����.@�#��H�{�,U2FC�5K_>_Y�o8��Ο/�d?#�\ux�v�� �,�luu�
Y�+�>ɵd�}�� ��(�ko�|�����Cz�0_���-��>���ԃCgN#�����o4� ?>+��f���E�d;B$�a&� ���Z����/u忽G��Z�7��Y�B �l�)Y�^f�$����B1hY���]�|.�t(&;�I��ޡyǮ+����ײ���Ϝ���a鱼ް�x0�,� ;d;I�S���:?��s���g��"���'M9�o1-���H��㡧�X�dv�VR�����0 �Kg��&d���w� ��cu�4�}�=W�{C!�;~ �d���S��,��k֟{ٴ�U.v�V�ޠ�L������_�xp�̙���/UH�-�dkq鎏����9�&��&��ٴ�n)����4��k��a�?��--h_�� G��}}R�����u؝AFq�`����h��IvX�P�@� @��0W|,���rJ;u����p������!��r;���!���v��a�L� ق�� KZh��9��r�,�����������>��e��ܿ=zb�T
��=��I���p��u�� ���x�жb�Ԙș��l!� 9~����_<w����i�� ����q[��gR�� �B�@���4$Cq ��.OK�bȗ���������&+��=<sڒ����?���Y^�L��n9B ���L���0�B �a4�'D4!K��.�;�{9 @c�,-��#i�����GѴ� ���$��}�s���?���� `4p�h6��������K�sw~NrTdw�@���E �
�O�u$�a4�l���Kp���%�����c�u�t
�@�`�pz�d��������x@�B�r� ׶=��$,L�%tq���qdI��h2�T�����t,[���eR��BcQx�L���c�˸'3 ��!�����c���q��/��l�SdYq���C���G"Ec�9��\<�nI�+��V��
����/E�p�¿�l6�:w�R��+�{+���@�� ��/b���TѱL(������2t����?�Nc4�H���K�Z}�L���|yM�k< ��� 8s�e�H6�YR*E�A%+�Ӑ
�P��l��^<#w����~a>�S��
��x�v%��GL�����n�(��(npY��F`�Ug ��ո� �7���4xC!��a�,���qYN:�@.�ܢ? 5Q*������+��?S�Ƙ? �$R��q
�,'6���DG�t��A�L� !`j��Α�x�yfh�� K�$RH'R�ð�L"�L"����%���3��� �V���aj�T!���vbO��fS�s7V_y �����> IDAT�@� r�ѱ :6t/��F���aj��^�����ڴ,��+p��O��l���\:�t$.; �JE�ȦҨ��]��ϟ~��u��׮+�V8k�@��e ��x�f��,@�}+�n~#��� �eDǂ�/e.qom=+�<�BF��
���Y���M��}�x��wf��s���ڵ������Y����Zf��9 �PLv��E���wuՂ�w��!�2�_�b. �ڏD �ڷ��Z�o�����B:μ�:�[�������h�2��,�1o�W�(�9�W���K����@���oNJ5#";2��(Q �Z�@Dv�[`����Aln�O�B�@��|!�r��,��uD}�����?���i1X�����/ (L�/D�F���yK�?}<R�2�?}����׊�C(.���qI�G�繺|���!y��Y�Qae���� il8�2u�q������х@�[Z*��E�A4u,���܍?w�`�#"��墸��_�m���F(.��9q I� F�������9$B�����P �˱��"'�!2��xF�P)�9�ё�1@R����0l/2D� ��Ϟ:���� D��ɯ���� 5�/@�m?�,͈�"����r� R7���ol@��4���W�� ?>��
5��,�� � ;$E��`3��<��Q�����Rk�^M��������ݗ�B]�'#,�8UA��h6�r��Ҷd�L"�L"�b�O��,��iP��Y���٬ט�� �d��8��,���}�l��Bj ޫY1�u�Y�;TV�t�K�-�cd�
G���(|�����ZU䱞k��PY�=A��?� E�̩����lA��k��b����M������ii�I?B�Q�a��Z�y(
YmbGtt ��Y ��� ��g}yS��/�0�JtU�pm'�M:��l
�5{py��"ҾArtd%�@e��������7�E�~Ē~�a���R�we��u7㹆u�t�g��}-���4�PYD�A��M��_�YLH����F�U Ȝ~�t~VrTd5n�RH��(B�����kF�&��&8��b�zrV�;:�w+:�� �,���t_zN��#���K_y�f�v��M�)2�."c,�k�hҏh�ۺ�4ޱk�� 0 @���L�D�e4�a8҈����fΜ| �>�y���p��Ъj%GE�0@�����0���?-�����WqkƋ�G�P�6���K��"I� �$�1d�Y�a8G��P
n���O��5U��o}ԋ��?�Tc���Hv�4a���!��q����߉�D����f�\n("��'�ù��,9B�����x
�x
��>;� s�?��}��ɯ3��5V�F@�b�L��� ��b�R���s_4-�|~�F��c�IV
�D�@�ˤ2H���)�x�-RP�����73����ot_ޏk\ Pq� Å<�B�� K�Kx!���^�힬�,�@�Ҳ�A��5Ð������j �\�g�S���Z�
�*8��.��0'��!ƹ2�+���g��T2v�0y-��/,; G�q�?�1-�,��9d��X�.d��8�ѿWv� /�v���.��,�����v�9��ŀ��j�d��{����x�>��GƚX po�бLd3���8�w| ����m�20@�yC�4��5���,/�vcG6��5x�� �l|���e�Ee�5�d���d��HC��I�j �ֲ?��AT�Ɖ��e�De� -Y<A6����p��̖U`|�nl�*�F��se�ܠ�l��%�dp��6�;+���*̉�o��ʈZ�D(�L2#; lj&}�&8�O�5깆��kX�,@E`-Z��'��&��?Ir�ě�z� w6 5�![�,9*23�h�X�Xj�҂D�^D9�O���^��v�/��������������ТF��Cp$��O��
d���s����:@+:ҢdR��q�a8G�$K5t썞�S�Te��k�y��|NrtdP�ƶ��C���% '��M~�I'1�����[���!���͜�E��`ZVC�% MzI���*�K�]�;���x��ܮ*(�������}_�"�S�`~��.�W5sZ���$[P�����ɯ Y�����Q�J���-H^�#�屿F��?Y�K������|Z�pʧ:� �`�$0������x��1������;���&_#;[b�r����J%t��
w-2J4�C4���R�ku���ϡ%�xpx~�����4 92�y�f��&�h�k&���Z'`�J B�j��p���HOA8�=4t����F|�������B���ZP�f�p�D�Ѡ�0'��!�3���#Ն��]W�;z�+�� /"Vų+J�R�*���႘���/G]܏ �=f�$Q_�tVv�38#;G���#p ׬��/��<��֖1*{ ��x��}�ko^��=?�ß��+�I�����K���-��tV�$X��[�7aC�g������CB�G_I^���O��?����j6��囡��d�E���W,E:���� NC~7��M��G�>P��M��������ѻ𙋟Dc�^z�VoA�{�oƾ��;�z��������E����w��D[zY��6���n�gĪb3>o}��|���]ov��'�l�x����*'2;4�T,�d,9�iA
�Z��\=���St���.�\����������3>�A��g.}�S �,�sp��7��Q��c|`����~tE�+k�A���J��6���ޅ����*���(�o�nt'��� /p�@ F<W1깊��u�#��b��f�I� ��h��_r�H�����o?o�w�ctZ�.����Y�������~����lvhG�d�#�$x<��>����+;��ѫ��\�g�h‹!��-����D[�uڵ�+�l|957�s"��l=��T;Z�ˋ�_�W���v��*\kD���$ cSs n=������ʥX�CvX�@�ЌrY a��5ܠ�7���ބ �uE��:�DUb�禪���M?��k�CWg�% ������4�r�F��(�z�M]��/�!V]�E�hS�Ќ|#^�# #E^���D*\xt�E׃�a���XI�! p��0���Īg����/��"6r��4 B�$��v�R����9����B�@E�Z�1��m�Ϲ����Gw�-U��m��+ڂ^�Z�5|{�ߡ��o�?��\=>s�Sxd�A�o Ņ���S/�)5V^~�Y�Q�`c�hO:G���$��$���o������{���5_Ņ�ˋz�dU���x}�~�U���
�����.=��\��߁ڋ5]��V�xN��[���F�:c�a8ΐ�������P��N���:^Y�ƒ^W@�`������#>˔@O��r�K�ݰ��r��Z�w�f�p-���һ l�jo�����Z$Y���u��q{�֢��m��p�6����}�L �����˟�#�U��A?��B^Qg֞|N��9��Ze�-�it]�?��ctX�������C���O���qe��뀡J�7�3�X�挻 ��ك�_zM���Y-�Vݐ���b�����1 `�Ф�?�lf�=Դ8s��x�m닮����y��-��)��qJ`?�Y9% �g��� �~��E�6�?%;[S��c�]-`��b��v�)�|������T��@�Zf솻ؠ��<��`�/ݞ�]hM�]u���+y�߿�K��)�OW��A/լ��oej ֱn--�s�m��.�U:/K�n�wB�a�V}���ދB~����k��|��˦x{�;��'��Dc���1=ѵ��s_�=����3N�W�p���g ;�.����@�NɑQ)*/oEE����3��pb���%zx�A�h7T�S^^���x�6^��l��Yo���z|���7%��ꕸ�j·n���p�z�쐨D����f56���
+�i�:R��#x[���m' ���S��~�:C
B�����/��f�8Q@��7n�yW��=rTN���
�N� ϼҙ�s�K�����ҧ<e\Y��u@RD� v |g�����𯞯�]Þ> {*g�θ�›w�'=�5���fkx��������X��ݑ��I�w�6����4M.�!���Q"�1Dc�}P�]� *����{G��wc+�L S���\�6��Ű����A��@������y��6�=-;[���v�5=ö�կCS��㛩�x���æBl�)����/cCt��x���;���Ͽ|�j��id5��������/�i�zi��� I�a�Ǣ?�U�������[wO���t�w��U����l=���h��,�֫pk��u7�5@����b�n�Q���?a��g�Ҳ��!;,���
� @�g8؄m�˛�R<b�m���J⻛��k�L (������C��w l���������_�e�<���Z;H�u�GY��H��"�Q�O�ڳu�ڱ#x{���x� ��y�Ƿ�&��`�!|��D�*^�3�)�/\�<��:��ݖ�Y��D�����E�c�ɟ��� 9TE�*�F�r���{�28vJ�箭��C�A-�������Ƕ���x �c���u�@kzzXz�F��R�C�j$'��w^z uq���@�Bc��躀w$(; G)���d�a[[Û�!������H�2���dU߽��xm��k ��k�"3׋5+�T
�A�Ƴ"��'X)�*��0!�L҇ jc�,�=�
y7
Ԇpx�Q���tj ���B�=���"�~
T�q�J5�o���Z�`��cC܊c$��/��ۍ�t���W�_C����l�MW�FכE?kWr%v�����텚h�X;$�"�L��Q�x��b������}E����q�咄���~�1 7 ]d�!���%Dd��R�w��;w�X;d�E Ź��y��j��Ӯ骎�W�g��B E���/C(b��:���<,)*��P��Y c-�
i�hɘu�Qw���c�m�&��9�p��[����p������I����.�Z$23��cq��4O ?���vmr�_x��u$��i���#�v\���;���z@(*���P���� � �H#��v*������7c�C���*�N�ͮ���w%;�����rjU�
��-8��?ť�kd�$GW���?���3����/�(G���� �a�_�6��G$D$����p{�Vtߐ�d�!�m9�dUr�g�ϋ՝pC�ŚN��j|*�Dw�쨈��fr�#��p�k�/ڞ�;�6C��W�_E^�K�H�JZP����k0�T�y�U������� �1J49�(G��R����SI ���b��+;���]n���?�c�����U⶿RT҂���}�U韑�ޜ���0:⇮WVZ�L�/^%o��O%-�j�Ӌ!f�b��t]�o8 �4PG5���ҷ�ͧ�N�������C׹WQ�Q�����)M�,��rZ�D-G���m��X�K��E\�7;� �'=/H��<��WP���p{�!�$���:��~QvX��u��a�����5���)�m QI ݊����K�����$
k�:/�F@9q�[�B6���SZ���E㶿�����z?��r=7~:`�k�D���Ji��E
�U�{�F*���>_�ݾ]�ӿ �ZC��� �Z��-Pʝ¾U�_xc��T�z�����Y��`�a"�(R���0��s��Z=~�—�������ʙ��G�mG��n�[������-����ŕ�g�|�8�g��j#�s����Ch�,���Z�ƽ�{��3�O =��\c�bY��T;��Vt�����C��k^t� �������"qĢ�9?\�r��g�':{�w�ߝ�5�t��oi*eA �r�� �A���T������o�7�����������Uo �NM�VX��c2 w8�ei����|i��9��ٌ ���l�N X�#�� ~��z�6��+��o!�UI��j����JG-�/ ������H��� �A�\���n��V�������V���ݍ������v��<��o�*aA �����LA_Xv�Q��a���7�WV���v�_a_�[ȸ2���U ������������x�� ���΢,�V]����Vc�5/v������^�A�� _� ��?�\y�\b�.Q}������=�z_S?^_�& ��p�i��O ���*����\…Չn���D�ֈ��1d]�g����ǰ6�f�5]��� ?D���ߧSŪ�hɶ�+�r����N�5�!Zm�����B��C��BZq�PK���s�W�᪒�c1`sZN����c�s�����Iw���[7�uI�r�`�?sU� �/Ԭ�Oj:�Նm���ո�/�� c1`s#�c�����(��4��H��g�>UL��>�~��f_`Ռ�'�}���ߌ+�����ʘ�p*MՐSs��i���\#�U ��{$Ef����%W��T@$��[����q����u���|�Q�9�_�>y+eX��<*�d��H/�Gz��:���#`[t�� %-�ڀ���3yÔ�`����(���S<����'7gLU ]�16�ѿQ�5�7��)�#�j�U)'N���H��0���{CHg8�j�oyF�f�0����ճڟ�� p�����JE���wQ���(�/�&ڍp����&;G�&�0�7�Pʶ?��c� ���Q �Q/����C�f���,B=;e����.V��?�d" ���5���mF1+#�mrU‚�&�ç3����>�"�\��n������l�FǤ���+�ܿ���V���VA࡜�B��>����'4fl&�#I@����\���0��p۟58}A�\5:X#�����@y��W�r���by����QRѡ�e�?8�jV����sU
\u�'2Cs�� � �DA��5̀����a���Iwb���_�\}������%��0;62<0E��H�*���.�q�������������mҲ��ސf͝��1���lWw��w1��t�xa�K�,|DRTƙ/ P�I�|�kS���VJ�B�����?2�`ٷ��zG���\,�m�0�0<˂��� p�,�s���l��)��re�_� �a�ߙZ���;��o=i��\�#5a�ѵ_bt4��Εh��5q� �3 F���!���|���k����Ǡ��?t3�4^�a��Ld��x����Z�!�J��L*���xb��������1^�Y�{��S�ȷv�s�� �P|�6�����
��:<,�c�r����7cCl}���]o#����ɪ$�tő����[Б6��85 �S��L�F���7���(��[�b������9�g�����%��~B� 5+�j��?6lŏb9 ��K���s�. 7����|�Օc����$��O *�8\� ����{Y�紝�&� !�b�_#�c����d�R2xp��#��ð-��Mp`x��������f�r��f�[E׻���8!p���{Yv���E�$�i�a8B9F�N��G�q�c��(�l7�,;54P�����m�#�����O\�W��
��4�����o�����9$�#��v1�(��������f;!pCl=��W�+1x�?�#l���a��#Xu�Y�!ق[��6ZX@Kf�述�Ϻ���^[�6G7�1�4��Cc�c�q�MW�f�"���ø5CU_��e
5Fw<�LS�����I&����p�r�� ���_��*W�*����O���*>��14��1� ��|�(��M��5tg`>�X���Q0+c�A���n�#���x �;�.�ޠ5�W���z�9k\|51���lÞ��/�KKW��?�������m�6�]_���z�+^���u�'2C�<v,d��(t���F0{�ߑjǎ��E�Y��J(?\�,bU���}`�l�/d�f���es�����q�{�IDAT1�b�q�t״kW�V0�}��"�N��3�P��O\}ʶ��X��H�(�+�ߒ��o{2����GK�� Q7����ʉ�����|�?�����'�@��ch�i*#D�^D����Y폜������d`F,l�62�c�_� �<�g�?r'��/ P��H���Zc@����[8��mI���?����8q=�LY�h�-���?@��Ert���d^��5̐���?����8m=��,�yW#���;p�g~��$GfM<
X"`�K�!��"�2o��m�To�|kk�����w�kg� ՝x�z9�� ���^�]�QvX�d�������)�a8�٣n�#�r�z��ZU���;p�e��X;]�ʒ�F0{��m�tN\0a`�#��ð$v$��㈆y�0���n��J���S1 03U�>�
m����?��Q%q���D��?����ۥ��D"  "; G0s��mTi��`��_��eW�a��#k�GKiW=����y��hj���?��Q%r�z˯Ǻ��6�� 4��`��g�� d7f�,��bl,(; G��W������9i=�z=����������뙻��o�:���|n�T�kW=�u}�Ҝb&��s�U:��Q�X�[Ӵ4-���P#�؏1��/�*B�ٗ�4rձ��z��w#��aDW�
��.�4��Q.���0K�a���?��]_�k�M7��?0���f\0h5�>�+d^����(d�M�>�]��̴�5�^D��7[}+z?�[�5,+{�f�O�����\9�T��1�F���Gt�S��X#@�X��b�Ut���i�4���kp��wh;e��:�9wl3G���G4���X)0�N@�rpg�P� 1��QUdZV!_�0����A�y���qy�:�a9��^h9ͩSIeM�7���?��9a=� 5+p��[hZj&6� Ps��܊+�*�M+��5�<�t���緝��~Z�=���~��t]��5���0�7o��mD3s��7f�))}5����_B�i@�����"�wO{L��|�.v�`t4�T&c����nѤѤ9)n�#���L] ��b��P,�}����U�N��:��Ã�0�00���# �8���?���}=��,�P1>�����ˇ��N_���%�}�WՔ'�2�����!��5@,�E̤�?������&�y�����*xv}����ǹ�)�]xs�s��V�/�2�9&��6q�/-�Y+����ha�~>�D��Y�hjjj�����ÝM ��6TǃXy䇨�^tdZV"޵U^�&P�dxw2I8�����R^�xÔ׾ol>8\�������9�����|�>=��p'��m�� ZE�ȡN��۩^���6��r����'~ ���)�������Gd����3��.��-���D�*��j�����*nd�8Fw�q7���i�}�+��ޢ&���mDKc��j�ְ��h3�hɴ�D����{^Bt������@(�]Y�!�F���G�tv_!�
ߪۀ}�������:@����U���
(�';�'�(͒��0��n�#2���Sc#8�Ѕ��O���1$��8���;\h���a`pLv�w~� C;�N ��Yd[ܺ<���w������~ �V6���*�C�����ɯ�1,�_B5��)�k���V[�0ǫ��ӽ_|��v�Mt�xW>���m���i�0X.��[o���gᘥ���8o���� �3~��o?��s7�j�߰��")B��{?����hӿ�V���lW+�Yc��j��q��/4�h�����G0��/K��<��oH00����F���;�Yo�������������`b=�����Oy����[�j1|=~�NC�N^o���1�������t]ǵA. [*�����g��O8u��*�Aҝ��~f��}d4T�hJ7��A�RXÐ�r�i9����y�G2C3�
`3� z����y3z�jY����[8�'2BW&2�c6$�e��tA���T
L���h���q������躎k\��Tf����"�=�*���8�R`Q���e�f��>C�z�Ȥ���si��������H��=¯��} (����ۢ�9��P;��e��<�+l�N��j\�������v0`����yGs3k���[=�c�� ��ŕ��<F���+�`�>o�K�.ՠߜ��^7ڮ�~�o�\�F/3DF8���v�~����6���Ys�x�1���Lã� ����䜖��=��q�ua�볓���.����Od�g�t�rS>2�\���P8Ȫ7� /֬��Zn!&�U���,���s.;K
��؞V;2i�?U���c�+OTǗ5���F�a,�\k�OA�砻�d�h~.Q_K�.����DD�n�$\5���I�o��cn�����ŋ'�������)�蟈�TY�Z��t�J�n|���Y���,A_�0 ��ס�8�'"�|$3�6�Ñ�e��j��G�]���.<#��U�Ӈk�W��}�i����"�3YxFX�w�8�'"�ܣ�ѩg�@.�����U����#���!H�5m�������'� �}���I8�'"�t�t��ɯ���G�~<��㒻o�[q�j�%&�� _q\��,B.������o�6��?��V-6��6k �Rz�9qe�p�89����F�g �,�Ձ1�u�����"2�5��V��j-��gЦ�����$qgp͓՝�Lpes<������,6�������Έ�F)K^$Y�Eَ�8.����Ć�m���p��M�E��E��i�:�� i`�@�&N�mԩ]�^�Z��Ȳ-خ-[2)�Z�I\D��l���"EQ�D9sfx���p��O|����]��֗g&[�Ri��o*�/��R׎��O�*r�hiLw��7��-2����X�*��ܼ��v]�G��{����jnI������#�^��X���~����u-�=�#'��� ���r��pF�RQr�N�{�Mz��j��u�����}��)N_B7����sǟT<w%P,CEz���59��������&W��!ݝT��x�м��Mm�S��*���ˆ>?uR��d���M�OtA�����
��T�_�r\� �}CL�+Ĺ� c��Lm��l�A/%[ͻ ��~37�/Oth���h�Aт;�v�@
�%r�:;�|_8_��g������B�,�� ���ƽz?q���FY}e�]{�:0w���� ��?_�;��g0.=N�h|<�=G=�qV�jTo�A�~�~аCv�#r\I4uR]Q�������������:Kt�MV�����@2��nثCa�E���8&sRp�v��]��:�P,��HFg��wE8��^d,�w��v(?o�L*R�+*���.�����lKp�X��u��?�zs �����Qop�v��Ly�Ү�����P\��DVg����su<2�V��Ro�A�Xߦ��������樠�0U�]
��h��^���F�w�:��?;Ԍ����]�Yj��%��,!��슊��ξw�� Y����� �w�6�˩��w��ƹkA$�?�]�AK��s������^����) ������d�z�[�\B��>El��"=���!/�Z�P�v�u�f��r���R�����?�`�|A'O�r] V�֢�aZ�kܩQ 5d)M)PT�iۓ��L��xK�Am���*#�'��x���V�6�_v��i��3#���7���9l)��JQ����c�5V�ֺɴ~�p��υbAЎ����X���5P���8~b@�|����G�0�k�s��ٻ$i2;��3�����{��.7� `����x��u��?�8ylݕ��r��0�B��b����/h��}�nkS��*��)�yz�51�������#N�����}b�dvBV���'���G���Xm.�(�pN��b�����Q��sõ�I �
3�J�&�� i�����
�9N�hll�w����@\}$ʨѕ�r��s�$+����'���=(�h�<�;B]c� �^ ���PS��sn��ƴ�{Nw1
�s�F2f�ߕ`� Ά-�aZK(��rN�o?]s�5p'BM��������`���۪����S����z���:���'�z�w����S�`� ������Hj>��R���]�@�љ�?ʒ�:������E3e����sA`Q�<���t�ž�N���7�;F]c���t�&eua`s��J����������J��u��?\l� 0n��9�XT�5����M��E��|A�'��V��?\l� ɔ�pN��������}�u���z�_���?,n� 0�@��b"�¦-raR���/�@)���͖�+��7l)�"L���f�,y�F��K��������N
e�#��u�_�BAq���T&��.瑆�r��*�ts��
ł�aR�{k��)�Z�{��@T�t��Oޟ�Sǣw��2��/3g���Ԟب�������{�O�sbY����$[��+�=�L��6��Z�bآB��B� I�|���wZ��s��<�gzX�����j�d.�dvBM��N�jC�{���a/yb��?=��q��-�x���Y�����Ͱ��.��(���(��G�{|G�k���|o$�$I�� 6�*J�T\�����:]�@żZ_L;>�����f��J�Q��|ǐ$��@u��}G���@ /�1D@ QC�q�C�� �(�!
b�GCt�!
b��� �(�!� ��C�1���:�wCt��P����� �jJ������r�O.�4�;���ФaIW�N��LC�d�����q��(�熂ȹ�s�*2u2u����; ����@`��,�
Q�=�A@�6����J��;���[�t`fNr��N*�ɽcf.���^�T��J���^�T�s�s~(I��Xy */��s���^IxM*ʤ���Z�t�8�iOy@�9s}p�ň�5�\����aK���Q�@ŝ-��/��0[����� ?�@��d�Y~��p�or?v�?�~&PI��G/�y��t�YI}UM*��%|~� w������*ə��Ya�k��7Q�=I�j�@%� ���E����!��8g?��l��_|s¾e�~���@%��7|}�|0��KK*��ޖ����E�f���*�T���s��I�`$:(���~$PQ��ۚ��f�����v$�"�jeR�J�̾���/]�hk�g%=���@Řܣ76��K��2:F�Wr�%�V-��L�M7l��K���I��d&����*���]n�������']������8��ãi�e�cr��$��aY ���#��UQ$���L�� I��l�͹��*�=pC����w/���v��i��t��s�Jyu4m�\��_Zf I�Cn_`6,��`� :g���ک�|hɧf���agv��ߜ3���N�R�$�I��ܷ��,X&�@[����ϖ�9��Mf��{ P&s?��)���E�|���$�Yt6�'�g�=(�~��a�����@�n5+K�����8`�������r+9H٧�::涄E�s�nX�����ݶ{� ��@+�̸a� f�Hj_��������՘��U*$麴u�� gzu�� $Io��nߝ���u�U+$���6��ۧ�Z�G�ݵg��^̓�j I���x.m�%s?Z�c3? ��g�Zml��*.�9t�Dߐ�_V�{X���=��)�����w)����uw�sK�V��` t�>}�=U�/��ʼ}�mO�wI�W���S��t�ui�����5 ik�SǛu����T�Vu�Izp�Y�Wc��<���q����N�W����#�/�n�����U��u]�^�n�GM���L���1����ͺ�ړ�����g��Q��t��T�I?-9}��;�1��Ϻ�"����W}g��^5�+��m�� 5Q��q�4�_I�5�YXE����]M���o9j���9�n��p��JJ��@�I�E���bO�3_M3:�܎DB���I;}�ಜ�d�aB����v�w���t0�y��]c�[ҽr�I;30Lj��ez�{���Ӭ�;���E0�a�RG�)g�G����C�3b�tXNϘ��x�^�g��i9����k�]iE�&�m��1�>,��w.��2*�;�^W��GI��{� ��u_,�k���@7I�NN�L�餭�Zύ��BI�|�x��T�4)i���t�I]2u�H��N�q�!+��]{�8zM6IEND�B`�
Display the source blob
Display the rendered blob
Raw
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
{
"name": "Slope viewer & editor",
"short_name": "Slope",
"start_url": "./slope.html",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2A5A8A",
"icons": [
{
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml"
},
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
}
]
}
<!DOCTYPE html>
<html lang="en">
<head>
<!-- https://gist.github.com/eddy-geek/ff8c4f3d74e1be6747baf3c91b41a50c -->
<!-- .../gh api --method PATCH /gists/ff8c4f3d74e1be6747baf3c91b41a50c -F "files[slope.html][content]=@slope.html" -F "files[slope.md][content]=@slope.md" --jq '.html_url' -->
<meta charset="utf-8" />
<title>Slope viewer & editor</title>
<meta name="viewport" content="width=device-width, initial-scale=1,maximum-scale=1,user-scalable=no" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<link rel="manifest" href="manifest.json" />
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.20.0/dist/maplibre-gl.css" />
<style>
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; }
#map { position: relative; width: 100%; height: 100%; }
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
#controls-wrapper {
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
}
#toolbar {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
#controls-toggle {
background: rgba(255,255,255,0.94);
border: none;
border-radius: 8px;
padding: 6px 12px;
box-shadow: 0 1px 6px rgba(0,0,0,0.25);
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
#controls-toggle:hover { background: rgba(240,240,240,0.97); }
#search-box {
position: relative;
display: flex;
align-items: center;
}
#search-icon {
background: rgba(255,255,255,0.94);
border: none;
border-radius: 8px;
padding: 6px 8px;
box-shadow: 0 1px 6px rgba(0,0,0,0.25);
font-size: 15px;
cursor: pointer;
line-height: 1;
}
#search-icon:hover { background: rgba(240,240,240,0.97); }
#search-input {
width: 0;
opacity: 0;
border: none;
outline: none;
font-size: 13px;
padding: 6px 8px;
border-radius: 0 8px 8px 0;
background: rgba(255,255,255,0.94);
box-shadow: 0 1px 6px rgba(0,0,0,0.25);
transition: width 0.25s ease, opacity 0.2s ease, padding 0.25s ease;
}
#search-box.expanded #search-input {
width: 200px;
opacity: 1;
padding: 6px 8px;
}
#search-box.expanded #search-icon {
border-radius: 8px 0 0 8px;
box-shadow: none;
}
#search-results {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background: rgba(255,255,255,0.97);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
max-height: 240px;
overflow-y: auto;
font-size: 12px;
z-index: 20;
}
#search-results.visible { display: block; }
.search-result {
padding: 7px 10px;
cursor: pointer;
border-bottom: 1px solid rgba(0,0,0,0.06);
line-height: 1.3;
}
.search-result:last-child { border-bottom: none; }
.search-result:hover { background: rgba(0,0,0,0.05); }
.search-result-name { font-weight: 600; }
.search-result-detail { opacity: 0.6; font-size: 11px; }
.panel-surface {
background: rgba(255,255,255,0.6);
border-radius: 12px;
box-shadow: 0 1px 6px rgba(0,0,0,0.25);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.panel-padded {
padding: 7px 9px;
}
#controls {
min-width: 180px;
font-size: 13px;
max-height: calc(100vh - 60px - var(--bottom-overlay-bottom));
overflow-y: auto;
}
#controls.collapsed { display: none; }
#advanced-toggle {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: none;
padding: 4px 0;
font-size: 12px;
font-weight: 600;
cursor: pointer;
opacity: 0.7;
width: 100%;
color: inherit;
}
#advanced-toggle:hover { opacity: 1; }
#advanced-toggle .arrow { display: inline-block; transition: transform 0.2s; font-size: 10px; }
#advanced-toggle .arrow.open { transform: rotate(90deg); }
#advanced-section { display: none; }
#advanced-section.open { display: block; }
#legend {
position: absolute;
left: 10px;
bottom: var(--bottom-overlay-bottom);
z-index: 10;
min-width: 260px;
font-size: 12px;
line-height: 1.3;
}
#legend.cursor-only .ramp,
#legend.cursor-only .labels {
display: none;
}
#legend.cursor-only #cursor-info {
margin-top: 0;
}
.row { margin: 6px 0; }
.legend-title { font-weight: 700; margin-bottom: 6px; }
.ramp {
height: 12px;
border-bottom: 1px solid rgba(0,0,0,0.4);
margin: 6px 0 4px;
}
.labels { display: flex; justify-content: space-between; opacity: 0.85; position: relative; }
.legend-ticks { position: absolute; top: -5px; left: 0; right: 0; height: 5px; display: flex; justify-content: space-between; pointer-events: none; }
.legend-ticks span { width: 1px; height: 8px; background: rgba(0,0,0,0.25); }
#cursor-info { margin-top: 6px; font-size: 11px; opacity: 0.85; }
#cursor-info code { background: rgba(0,0,0,0.05); padding: 1px 4px; border-radius: 3px; }
#status { font-size: 12px; opacity: 0.85; }
#status code { background: rgba(0,0,0,0.05); padding: 1px 4px; border-radius: 3px; }
/* Track editor */
.tb-btn {
background: rgba(255,255,255,0.94);
border: none;
border-radius: 8px;
padding: 6px 8px;
box-shadow: 0 1px 6px rgba(0,0,0,0.25);
font-size: 15px;
cursor: pointer;
line-height: 1;
}
.tb-btn:hover { background: rgba(240,240,240,0.97); }
.tb-btn.active { background: #4a90d9; color: #fff; box-shadow: 0 1px 6px rgba(74,144,217,0.5); }
.tb-btn:disabled {
opacity: 0.4;
cursor: default;
color: rgba(0,0,0,0.45);
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
.tb-btn:disabled:hover { background: rgba(255,255,255,0.94); }
#track-panel-shell {
position: absolute;
top: 10px;
right: 10px;
z-index: 11;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
#track-panel-shell.visible {
padding: 8px 10px 10px;
align-items: stretch;
gap: 0;
}
#track-tool-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
#draw-btn,
#tracks-btn {
position: static;
z-index: auto;
}
#track-panel {
position: static;
z-index: auto;
background: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
font-size: 12px;
min-width: 180px;
max-height: 50vh;
overflow-y: auto;
display: none;
}
#track-panel.visible { display: block; }
.track-panel-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
#track-panel h3 { margin: 0; font-size: 13px; }
#track-panel .track-panel-header #profile-toggle-btn {
margin-left: auto;
}
#track-panel .track-panel-header #track-tool-row {
flex-shrink: 0;
}
#profile-toggle-btn {
background: rgba(74,144,217,0.12);
color: #245480;
border: none;
border-radius: 999px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
#profile-toggle-btn:hover { background: rgba(74,144,217,0.18); }
#profile-toggle-btn.active { background: #4a90d9; color: #fff; }
#profile-toggle-btn:disabled { opacity: 0.45; cursor: default; }
.track-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 0;
border-bottom: 1px solid rgba(0,0,0,0.06);
cursor: pointer;
}
.track-item:last-child { border-bottom: none; }
.track-item.active { font-weight: 700; }
.track-item .track-color {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.track-item .track-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.track-stats { font-size: 10px; opacity: 0.65; margin-left: 14px; }
.track-item .track-del {
background: none; border: none; cursor: pointer; font-size: 13px; opacity: 0.5; padding: 0 2px;
}
.track-item .track-del:hover { opacity: 1; }
.track-group-header {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 0 2px;
cursor: pointer;
font-size: 12px;
border-bottom: 1px solid rgba(0,0,0,0.08);
}
.track-group-header.active { font-weight: 700; }
.track-group-toggle { font-size: 10px; flex-shrink: 0; width: 10px; text-align: center; }
.track-item-nested { padding-left: 18px; }
.track-export-bar { display: flex; gap: 4px; margin-top: 6px; }
.track-export-bar button {
flex: 1;
background: #4a90d9;
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 6px;
font-size: 11px;
cursor: pointer;
}
.track-export-bar button:hover { background: #3a7bc8; }
#drop-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(74,144,217,0.25);
border: 3px dashed #4a90d9;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 700;
color: #2a5a8a;
pointer-events: none;
}
#drop-overlay.visible { display: flex; }
#mobile-move-hint {
display: none;
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
background: rgba(0,0,0,0.75);
color: #fff;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
white-space: nowrap;
}
#mobile-move-hint.visible { display: block; }
:root {
--bottom-overlay-bottom: 12px;
--bottom-right-surface: rgba(255,255,255,0.28);
--bottom-right-surface-hover: rgba(255,255,255,0.62);
}
body.profile-open {
--bottom-overlay-bottom: 148px;
}
.maplibregl-ctrl-bottom-right {
right: 10px;
bottom: var(--bottom-overlay-bottom);
}
.maplibregl-ctrl-bottom-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.maplibregl-ctrl-bottom-right .maplibregl-ctrl-group,
.maplibregl-ctrl-bottom-right .maplibregl-ctrl {
margin: 0;
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
background: var(--bottom-right-surface);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
width: fit-content;
max-width: none;
}
.maplibregl-ctrl-bottom-right .maplibregl-ctrl-group > button,
.maplibregl-ctrl-bottom-right .maplibregl-ctrl button {
background: transparent;
width: 40px;
height: 40px;
}
.maplibregl-ctrl-bottom-right .maplibregl-ctrl-group > button:hover,
.maplibregl-ctrl-bottom-right .maplibregl-ctrl button:hover {
background: rgba(255,255,255,0.14);
}
.maplibregl-ctrl-bottom-right .maplibregl-ctrl-group + .maplibregl-ctrl-group {
margin-top: 0;
}
.maplibregl-ctrl-bottom-right .maplibregl-ctrl-geolocate {
border-radius: 999px;
overflow: hidden;
}
.maplibregl-ctrl-bottom-right .maplibregl-ctrl-scale {
align-self: flex-end;
background: var(--bottom-right-surface);
border-radius: 8px;
padding: 3px 8px;
min-width: 0;
text-align: center;
color: rgba(0,0,0,0.8);
box-sizing: border-box;
}
/* Elevation profile */
#profile-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 20;
border-top: 1px solid rgba(0,0,0,0.12);
height: 140px;
display: none;
}
#profile-panel.visible { display: block; }
#profile-panel canvas { width: 100% !important; }
#profile-close {
position: absolute;
top: 2px;
right: 6px;
background: none;
border: none;
font-size: 16px;
cursor: pointer;
opacity: 0.5;
z-index: 1;
line-height: 1;
}
#profile-close:hover { opacity: 1; }
/* Cursor tooltip (follows mouse) */
#cursor-tooltip {
position: fixed;
z-index: 30;
pointer-events: none;
background: rgba(255,255,255,0.92);
border-radius: 6px;
padding: 3px 7px;
font-size: 11px;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
white-space: nowrap;
display: none;
}
#cursor-tooltip.visible { display: block; }
#cursor-tooltip code { background: rgba(0,0,0,0.05); padding: 1px 3px; border-radius: 2px; }
/* Mobile crosshair (appears on tap) */
#mobile-crosshair {
position: fixed;
z-index: 25;
pointer-events: none;
display: none;
width: 0; height: 0;
}
#mobile-crosshair.visible { display: block; }
#mobile-crosshair::before,
#mobile-crosshair::after {
content: '';
position: absolute;
background: rgba(0,0,0,0.55);
}
#mobile-crosshair::before { width: 1px; height: 24px; left: 0; top: -12px; }
#mobile-crosshair::after { width: 24px; height: 1px; left: -12px; top: 0; }
/* Draw-mode crosshair (center of screen, mobile) */
#draw-crosshair {
position: absolute;
top: 50%; left: 50%;
z-index: 15;
pointer-events: none;
display: none;
width: 0; height: 0;
}
#draw-crosshair.visible { display: block; }
#draw-crosshair::before,
#draw-crosshair::after {
content: '';
position: absolute;
background: rgba(74,144,217,0.7);
}
#draw-crosshair::before { width: 2px; height: 30px; left: -1px; top: -15px; }
#draw-crosshair::after { width: 30px; height: 2px; left: -15px; top: -1px; }
/* Toast notification */
#toast {
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
z-index: 30;
background: rgba(0,0,0,0.78);
color: #fff;
padding: 8px 18px;
border-radius: 20px;
font-size: 13px;
white-space: nowrap;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
#toast.visible { opacity: 1; }
/* Track edit button in track list */
.track-item .track-edit {
background: none; border: none; cursor: pointer;
font-size: 12px; opacity: 0.5; padding: 0 2px;
}
.track-item .track-edit:hover { opacity: 1; }
.track-item .track-edit.active { opacity: 1; color: #4a90d9; }
/* Undo-last-point button */
#undo-btn { font-size: 14px; }
/* Mobile-friendly mode button */
#mobile-mode-btn { font-size: 14px; }
#mobile-mode-btn.active { background: #4a90d9; color: #fff; }
/* On-map insert popup near selected vertex */
.vertex-insert-popup { cursor: pointer; }
.vertex-insert-popup .insert-popup-btn {
background: rgba(255,255,255,0.94);
border: 1.5px solid #4a90d9;
border-radius: 50%;
width: 24px; height: 24px;
font-size: 16px; font-weight: 700;
color: #4a90d9;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 1px 4px rgba(0,0,0,0.18);
line-height: 1;
}
.vertex-insert-popup .insert-popup-btn:hover,
.vertex-insert-popup .insert-popup-btn.active {
background: #4a90d9; color: #fff;
}
/* end track editor */
</style>
<script src="https://unpkg.com/maplibre-gl@5.20.0/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/maplibre-contour@0.0.5/dist/index.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3/dist/chartjs-plugin-annotation.min.js"></script>
</head>
<body>
<div id="map"></div>
<div id="controls-wrapper">
<div id="toolbar">
<button id="controls-toggle">🌍 Settings ▾</button>
<div id="search-box">
<button id="search-icon">&#128269;</button>
<input id="search-input" type="text" placeholder="Search location..." autocomplete="off" />
<div id="search-results"></div>
</div>
</div>
<div id="controls" class="panel-surface panel-padded">
<div class="row">
<label for="mode"><strong>Mode</strong></label><br />
<select id="mode">
<option value=""></option>
<option value="slope+relief" selected>Slope + Color relief</option>
<option value="slope">Slope</option>
<option value="aspect">Aspect</option>
<option value="color-relief">Color relief</option>
</select>
</div>
<div class="row">
<label for="basemap"><strong>Basemap</strong></label><br />
<select id="basemap">
<option value="osm" selected>OSM</option>
<option value="otm">OTM</option>
<option value="ign-plan">IGN plan (FR)</option>
<option value="swisstopo-vector">SwissTopo vector</option>
<option value="kartverket">Kartverket topo (NO)</option>
</select>
</div>
<div class="row">
<label><input id="showContours" type="checkbox" checked /> Contour lines</label>
</div>
<div class="row">
<label><input id="showOpenSkiMap" type="checkbox" /> OpenSkiMap overlay</label>
</div>
<div class="row">
<label><input id="terrain3d" type="checkbox" /> 3D terrain</label>
</div>
<button id="advanced-toggle" type="button"><span class="arrow">&#9654;</span> Advanced</button>
<div id="advanced-section">
<div class="row">
<label for="basemapOpacity"><strong>Basemap opacity</strong>: <span id="basemapOpacityValue">1.00</span></label><br />
<input id="basemapOpacity" type="range" min="0" max="1" step="0.01" value="1" />
</div>
<div class="row">
<label for="slopeOpacity"><strong>Analysis opacity</strong>: <span id="slopeOpacityValue">0.45</span></label><br />
<input id="slopeOpacity" type="range" min="0" max="1" step="0.01" value="0.45" />
</div>
<div class="row">
<label for="hillshadeOpacity"><strong>Hillshade opacity</strong>: <span id="hillshadeOpacityValue">0.10</span></label><br />
<input id="hillshadeOpacity" type="range" min="0" max="1" step="0.01" value="0.10" />
</div>
<div class="row">
<label for="hillshadeMethod"><strong>Hillshade method</strong></label><br />
<select id="hillshadeMethod">
<option value="standard">Standard</option>
<option value="basic">Basic</option>
<option value="combined">Combined</option>
<option value="multidirectional" selected>Multidirectional</option>
<option value="igor">Igor</option>
</select>
</div>
<div class="row">
<label for="cursorInfoMode"><strong>Elevation &amp; slope</strong></label><br />
<select id="cursorInfoMode">
<option value="cursor" selected>At cursor</option>
<option value="corner">Corner</option>
<option value="no">No</option>
</select>
</div>
<div class="row">
<label><input id="multiplyBlend" type="checkbox" checked /> Multiply blend</label>
</div>
<div class="row">
<label><input id="showTileGrid" type="checkbox" /> Show DEM tile grid</label>
</div>
<div class="row">
<label for="terrainExaggeration"><strong>Terrain exaggeration</strong>: <span id="terrainExaggerationValue">1.40</span></label><br />
<input id="terrainExaggeration" type="range" min="1" max="3" step="0.1" value="1.4" disabled />
</div>
<div id="status" class="row">
Internal backfilled DEM tiles: <code id="internalCount">0</code><br />
Fallback border-fixed tiles: <code id="fallbackCount">0</code>
</div>
<div class="row">
<button id="clear-data-btn" type="button" class="tb-btn" title="Clear saved tracks and settings from localStorage">Clear saved data</button>
</div>
</div>
</div>
</div>
<div id="track-panel-shell">
<div id="track-tool-row">
<button id="mobile-mode-btn" class="tb-btn" title="Mobile-friendly edit mode" style="display:none">📱</button>
<button id="undo-btn" class="tb-btn" title="Delete last point" style="display:none">🗑️</button>
<button id="draw-btn" class="tb-btn" title="Draw track (click to add points, double-click to finish)">&#9998;</button>
<button id="tracks-btn" class="tb-btn" title="Track list" disabled>📍</button>
</div>
<div id="track-panel">
<div class="track-panel-header">
<h3>Tracks</h3>
<button id="profile-toggle-btn" type="button">Profile</button>
</div>
<div id="track-list"></div>
<div class="track-export-bar">
<button id="export-gpx-btn">Export GPX</button>
<button id="export-geojson-btn">Export GeoJSON</button>
<button id="export-all-gpx-btn">Export All</button>
</div>
<div class="track-export-bar">
<button id="open-folder-btn">Open folder…</button>
<button id="save-folder-btn">Save to folder…</button>
</div>
</div>
</div>
<div id="drop-overlay">Drop GPX / GeoJSON file here</div>
<div id="mobile-move-hint">Pan map to move point &bull; tap elsewhere to deselect</div>
<div id="cursor-tooltip"></div>
<div id="mobile-crosshair"></div>
<div id="draw-crosshair"></div>
<div id="toast"></div>
<div id="profile-panel" class="panel-surface">
<button id="profile-close" title="Close">&times;</button>
<canvas id="profile-canvas"></canvas>
</div>
<div id="legend" class="panel-surface panel-padded" style="background: rgba(255,255,255,0.3);">
<div id="legendRamp" class="ramp" title="Slope (degrees)"></div>
<div id="legendLabels" class="labels"></div>
<div id="cursor-info">Elevation: <code id="cursorElevation">n/a</code> &middot; Slope: <code id="cursorSlope">n/a</code></div>
</div>
<script>
(() => {
var __getOwnPropNames = Object.getOwnPropertyNames;
var __esm = (fn, res) => function __init() {
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
};
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
// utils.js
function haversineKm(a, b) {
const R = 6371;
const dLat = (b[1] - a[1]) * Math.PI / 180;
const dLon = (b[0] - a[0]) * Math.PI / 180;
const la = a[1] * Math.PI / 180, lb = b[1] * Math.PI / 180;
const h = Math.sin(dLat / 2) ** 2 + Math.cos(la) * Math.cos(lb) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
}
function normalizeTileX(x, z) {
const n = Math.pow(2, z);
return (x % n + n) % n;
}
function lonLatToTile(lon, lat, z) {
const n = Math.pow(2, z);
const x = Math.floor((lon + 180) / 360 * n);
const latRad = lat * Math.PI / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
return { x, y };
}
function tileToLngLatBounds(x, y, z) {
const n = Math.pow(2, z);
const west = x / n * 360 - 180;
const east = (x + 1) / n * 360 - 180;
const northRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n)));
const southRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 1) / n)));
return {
west,
east,
north: northRad * 180 / Math.PI,
south: southRad * 180 / Math.PI
};
}
function mercatorVertsForTile(z, x, y, wrap) {
const n = Math.pow(2, z);
const x0 = x / n + wrap;
const x1 = (x + 1) / n + wrap;
const y0 = y / n;
const y1 = (y + 1) / n;
return new Float32Array([
x0,
y0,
0,
0,
x0,
y1,
0,
1,
x1,
y0,
1,
0,
x1,
y1,
1,
1
]);
}
function decodeTerrarium(r, g, b) {
return r * 256 + g + b / 256 - 32768;
}
function encodeTerrarium(elevation, out, byteIndex) {
const v = elevation + 32768;
let r = Math.floor(v / 256);
let g = Math.floor(v - r * 256);
let b = Math.round((v - r * 256 - g) * 256);
r = Math.max(0, Math.min(255, r));
g = Math.max(0, Math.min(255, g));
b = Math.max(0, Math.min(255, b));
out[byteIndex] = r;
out[byteIndex + 1] = g;
out[byteIndex + 2] = b;
out[byteIndex + 3] = 255;
}
function cssColorToRgb01(color) {
colorCtx.clearRect(0, 0, 1, 1);
colorCtx.fillStyle = "#000000";
colorCtx.fillStyle = color;
colorCtx.fillRect(0, 0, 1, 1);
const pixel = colorCtx.getImageData(0, 0, 1, 1).data;
return [pixel[0] / 255, pixel[1] / 255, pixel[2] / 255];
}
function downloadFile(name, content, mime) {
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([content], { type: mime }));
a.download = name;
a.click();
URL.revokeObjectURL(a.href);
}
function parseBooleanParam(value) {
if (value == null || value === "") return null;
const normalized = String(value).trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
return null;
}
var colorCanvas, colorCtx;
var init_utils = __esm({
"utils.js"() {
colorCanvas = document.createElement("canvas");
colorCanvas.width = 1;
colorCanvas.height = 1;
colorCtx = colorCanvas.getContext("2d", { willReadFrequently: true });
}
});
// constants.js
function demTileUrl(z, x, y) {
return DEM_TILE_URL_TEMPLATE.replace("{z}", String(z)).replace("{x}", String(x)).replace("{y}", String(y));
}
function parseStepRamp(expression, expectedInput) {
if (!Array.isArray(expression) || expression[0] !== "step") {
throw new Error("Color expression must be a step expression");
}
const input = expression[1];
if (!Array.isArray(input) || input[0] !== expectedInput) {
throw new Error(`Step input must be ["${expectedInput}"]`);
}
const defaultColor = expression[2];
const stepCount = Math.floor((expression.length - 3) / 2);
if (stepCount > MAX_STEP_STOPS) {
throw new Error(`Too many step stops. Max supported is ${MAX_STEP_STOPS}`);
}
const values = new Float32Array(MAX_STEP_STOPS);
const colors = new Float32Array((MAX_STEP_STOPS + 1) * 3);
const c0 = cssColorToRgb01(defaultColor);
colors[0] = c0[0];
colors[1] = c0[1];
colors[2] = c0[2];
for (let i = 0; i < stepCount; i++) {
values[i] = Number(expression[3 + i * 2]);
const c = cssColorToRgb01(expression[4 + i * 2]);
colors[(i + 1) * 3 + 0] = c[0];
colors[(i + 1) * 3 + 1] = c[1];
colors[(i + 1) * 3 + 2] = c[2];
}
return { stepCount, values, colors };
}
function parseInterpolateStops(expression, expectedInput) {
if (!Array.isArray(expression) || expression[0] !== "interpolate") {
throw new Error("Color expression must be an interpolate expression");
}
const input = expression[2];
if (!Array.isArray(input) || input[0] !== expectedInput) {
throw new Error(`Interpolate input must be ["${expectedInput}"]`);
}
const stops = [];
for (let i = 3; i < expression.length; i += 2) {
stops.push({
value: Number(expression[i]),
color: String(expression[i + 1])
});
}
return stops;
}
function rampToLegendCss(mode) {
const ramp = PARSED_RAMPS[mode];
const range = ANALYSIS_RANGE[mode];
const min = range[0];
const max = range[1];
const parts = [];
function pct(value) {
return Math.max(0, Math.min(100, (value - min) / (max - min) * 100));
}
function rgb(i) {
const r = Math.round(ramp.colors[i * 3 + 0] * 255);
const g = Math.round(ramp.colors[i * 3 + 1] * 255);
const b = Math.round(ramp.colors[i * 3 + 2] * 255);
return `rgb(${r}, ${g}, ${b})`;
}
for (let i = 0; i <= ramp.stepCount; i++) {
const startPct = i === 0 ? 0 : pct(ramp.values[i - 1]);
const endPct = i < ramp.stepCount ? pct(ramp.values[i]) : 100;
parts.push(`${rgb(i)} ${startPct.toFixed(2)}% ${endPct.toFixed(2)}%`);
}
return `linear-gradient(to right, ${parts.join(", ")})`;
}
function interpolateStopsToLegendCss(stops) {
const parts = stops.map(({ value, color }, index) => {
const position = Math.max(0, Math.min(100, (value - stops[0].value) / (stops[stops.length - 1].value - stops[0].value) * 100));
return `${color} ${position.toFixed(2)}%`;
});
return `linear-gradient(to right, ${parts.join(", ")})`;
}
var DEM_TILE_URL_TEMPLATE, MAX_STEP_STOPS, DEM_SOURCE_ID, DEM_MAX_Z, SLOPE_RELIEF_CROSSFADE_Z, CORE_DIM, PAD_STRIDE, TRACK_COLORS, ANALYSIS_COLOR, ANALYSIS_RANGE, PARSED_RAMPS, COLOR_RELIEF_STOPS, BASEMAP_LAYER_GROUPS, BASEMAP_DEFAULT_VIEW, OPENSKIMAP_LAYER_IDS, ALL_BASEMAP_LAYER_IDS;
var init_constants = __esm({
"constants.js"() {
init_utils();
DEM_TILE_URL_TEMPLATE = "https://tiles.mapterhorn.com/{z}/{x}/{y}.webp";
MAX_STEP_STOPS = 16;
DEM_SOURCE_ID = "dem";
DEM_MAX_Z = 14;
SLOPE_RELIEF_CROSSFADE_Z = 11;
CORE_DIM = 512;
PAD_STRIDE = CORE_DIM + 2;
TRACK_COLORS = ["#e040fb", "#ff5252", "#00e676", "#ffab00", "#2979ff", "#00e5ff", "#ff6e40", "#d500f9"];
ANALYSIS_COLOR = {
slope: [
"step",
["slope"],
"#ffffff",
// white
20,
"#c0ffff",
// light sky blue
24,
"#57ffff",
// bright cyan
28,
"#00d3db",
// aqua blue
31,
"#fffa32",
// sunshine yellow
34,
"#ffc256",
// macaroni
37,
"#fd7100",
// orange
40,
"#ff0000",
// cherry red
43,
"#e958ff",
// heliotrope
47,
"#a650ff",
// lighter purple
52,
"#5e1eff",
// purplish blue
57,
"#0000ff",
// rich blue
65,
"#aaaaaa"
// cool grey
],
aspect: [
"step",
["aspect"],
"#ff0000",
45,
"#ffff00",
135,
"#00ff00",
225,
"#00ffff",
315,
"#0000ff"
],
"color-relief": [
"interpolate",
["linear"],
["elevation"],
-250,
"#315C8D",
-0.1,
"#A9D4E8",
0,
"#A9D4E8",
0.1,
"#A9D4E8",
50,
"#809E47",
100,
"#B3C57D",
250,
"#D1D98C",
500,
"#C8B75F",
750,
"#A38766",
1e3,
"#836A4E",
1500,
"#705B43",
2e3,
"#604E39",
2500,
"#C2AB94",
3e3,
"#D9CCBF",
4e3,
"#ECE6DF",
5e3,
"#F6F2EF",
6e3,
"#FFFFFF",
8e3,
"#F5FDFF"
]
};
ANALYSIS_RANGE = {
slope: [0, 90],
aspect: [0, 360],
"color-relief": [-250, 8e3]
};
PARSED_RAMPS = {
slope: parseStepRamp(ANALYSIS_COLOR.slope, "slope"),
aspect: parseStepRamp(ANALYSIS_COLOR.aspect, "aspect")
};
COLOR_RELIEF_STOPS = parseInterpolateStops(ANALYSIS_COLOR["color-relief"], "elevation");
BASEMAP_LAYER_GROUPS = {
"osm": ["basemap-osm"],
"otm": ["basemap-otm"],
"ign-plan": ["basemap-ign"],
"swisstopo-vector": [
"basemap-swiss-landcover",
"basemap-swiss-water",
"basemap-swiss-transport",
"basemap-swiss-boundary",
"basemap-swiss-label"
],
"kartverket": ["basemap-kartverket"]
};
BASEMAP_DEFAULT_VIEW = {
"kartverket": { center: [13, 67], zoom: 6, bounds: [3, 57, 32, 72] },
"ign-plan": { center: [2.35, 46.8], zoom: 6, bounds: [-5.5, 41, 10, 51.5] },
"swisstopo-vector": { center: [8.23, 46.82], zoom: 8, bounds: [5.9, 45.8, 10.5, 47.8] }
};
OPENSKIMAP_LAYER_IDS = [
"basemap-ski-areas",
"basemap-ski-runs",
"basemap-ski-lifts",
"basemap-ski-spots"
];
ALL_BASEMAP_LAYER_IDS = [...new Set(Object.values(BASEMAP_LAYER_GROUPS).flat())];
}
});
// state.js
function createStore(initial, onChange) {
return new Proxy({ ...initial }, {
set(target, key, value) {
const old = target[key];
target[key] = value;
if (old !== value && onChange) onChange(key, value, old);
return true;
}
});
}
var STATE_DEFAULTS;
var init_state = __esm({
"state.js"() {
STATE_DEFAULTS = {
mode: "slope+relief",
basemap: "osm",
basemapOpacity: 1,
hillshadeOpacity: 0.1,
hillshadeMethod: "igor",
slopeOpacity: 0.45,
effectiveSlopeOpacity: 0.45,
showContours: true,
showOpenSkiMap: false,
showTileGrid: false,
cursorInfoMode: "cursor",
multiplyBlend: true,
terrain3d: false,
terrainExaggeration: 1.4,
internalCount: 0,
fallbackCount: 0
};
}
});
// ui.js
function setLayerVisibilitySafe(map4, layerId, visible) {
if (!map4.getLayer(layerId)) return;
map4.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
}
function setGlobalStatePropertySafe(map4, name, value) {
if (typeof map4.setGlobalStateProperty === "function") {
map4.setGlobalStateProperty(name, value);
}
}
function basemapOpacityExpr(multiplier = 1) {
const base = ["coalesce", ["global-state", "basemapOpacity"], 1];
if (multiplier === 1) return base;
return ["*", multiplier, base];
}
function applyBasemapSelection(map4, state4, flyIfOutside) {
const activeList = BASEMAP_LAYER_GROUPS[state4.basemap] || BASEMAP_LAYER_GROUPS.osm;
const active = new Set(activeList);
for (const layerId of ALL_BASEMAP_LAYER_IDS) {
setLayerVisibilitySafe(map4, layerId, active.has(layerId));
}
for (const layerId of activeList) {
if (map4.getLayer(layerId) && map4.getLayer("dem-loader")) {
map4.moveLayer(layerId, "dem-loader");
}
}
for (const layerId of OPENSKIMAP_LAYER_IDS) {
if (map4.getLayer(layerId) && map4.getLayer("dem-loader")) {
map4.moveLayer(layerId, "dem-loader");
}
}
if (flyIfOutside) {
const dv = BASEMAP_DEFAULT_VIEW[state4.basemap];
if (dv && dv.bounds) {
const c = map4.getCenter();
const [w, s, e, n] = dv.bounds;
if (c.lng < w || c.lng > e || c.lat < s || c.lat > n) {
map4.flyTo({ center: dv.center, zoom: dv.zoom, duration: 1500 });
}
}
}
const shouldShowContours = state4.basemap === "osm";
state4.showContours = shouldShowContours;
const contourCb = document.getElementById("showContours");
if (contourCb) contourCb.checked = shouldShowContours;
applyContourVisibility(map4, state4);
}
function applyContourVisibility(map4, state4) {
setLayerVisibilitySafe(map4, "contours", state4.showContours);
setLayerVisibilitySafe(map4, "contour-text", state4.showContours);
}
function applyOpenSkiMapOverlay(map4, state4) {
for (const id of OPENSKIMAP_LAYER_IDS) {
setLayerVisibilitySafe(map4, id, state4.showOpenSkiMap);
}
}
function applyTerrainState(map4, state4) {
if (state4.terrain3d) {
map4.setTerrain({ source: DEM_SOURCE_ID, exaggeration: state4.terrainExaggeration });
} else {
map4.setTerrain(null);
}
}
function updateLegend(state4, map4) {
const legend = document.getElementById("legend");
const ramp = document.getElementById("legendRamp");
const labels = document.getElementById("legendLabels");
const effectiveMode = state4.mode === "slope+relief" ? typeof map4 !== "undefined" && map4.getZoom() < SLOPE_RELIEF_CROSSFADE_Z ? "color-relief" : "slope" : state4.mode;
if (!effectiveMode) {
legend.classList.add("cursor-only");
ramp.title = "";
ramp.style.background = "none";
labels.innerHTML = "";
} else if (effectiveMode === "slope") {
legend.classList.remove("cursor-only");
ramp.title = "Slope (degrees)";
ramp.style.background = rampToLegendCss("slope");
labels.innerHTML = '<div class="legend-ticks"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div><div class="legend-ticks"><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div><span>0\xB0</span><span>15\xB0</span><span>30\xB0</span><span>45\xB0</span><span>60\xB0</span><span>75\xB0</span><span>90\xB0</span>';
} else if (effectiveMode === "aspect") {
legend.classList.remove("cursor-only");
ramp.title = "Aspect (compass direction)";
ramp.style.background = rampToLegendCss("aspect");
labels.innerHTML = '<div class="legend-ticks"><span></span><span></span><span></span><span></span><span></span></div><span>N</span><span>E</span><span>S</span><span>W</span><span>N</span>';
} else {
legend.classList.remove("cursor-only");
ramp.title = "Elevation color relief (meters)";
ramp.style.background = interpolateStopsToLegendCss(COLOR_RELIEF_STOPS);
labels.innerHTML = '<div class="legend-ticks"><span></span><span></span><span></span><span></span><span></span><span></span></div><span>-250 m</span><span>0 m</span><span>500 m</span><span>1500 m</span><span>3000 m</span><span>8000 m</span>';
}
}
function computeEffectiveSlopeOpacity(state4, map4) {
if (state4.mode !== "slope+relief") {
state4.effectiveSlopeOpacity = state4.slopeOpacity;
return;
}
const z = map4.getZoom();
const t = Math.max(0, Math.min(1, z - (SLOPE_RELIEF_CROSSFADE_Z - 1)));
state4.effectiveSlopeOpacity = state4.slopeOpacity * t;
}
function applyModeState(map4, state4) {
computeEffectiveSlopeOpacity(state4, map4);
if (map4.getLayer("dem-color-relief")) {
if (state4.mode === "slope+relief") {
map4.setLayoutProperty("dem-color-relief", "visibility", "visible");
map4.setPaintProperty(
"dem-color-relief",
"color-relief-opacity",
[
"interpolate",
["linear"],
["zoom"],
SLOPE_RELIEF_CROSSFADE_Z - 1,
state4.slopeOpacity,
SLOPE_RELIEF_CROSSFADE_Z,
0
]
);
} else {
map4.setLayoutProperty("dem-color-relief", "visibility", state4.mode === "color-relief" ? "visible" : "none");
map4.setPaintProperty("dem-color-relief", "color-relief-opacity", state4.slopeOpacity);
}
}
}
function setCursorInfo(state4, eleText, slopeText) {
if (state4.cursorInfoMode === "corner") {
const el = document.getElementById("cursorElevation");
if (el) el.textContent = eleText;
const sl = document.getElementById("cursorSlope");
if (sl) sl.textContent = slopeText || "n/a";
}
}
function showCursorTooltipAt(state4, screenX, screenY, eleText, slopeText) {
if (state4.cursorInfoMode !== "cursor") {
cursorTooltip.classList.remove("visible");
return;
}
cursorTooltip.innerHTML = `Elev: <code>${eleText}</code> \xB7 Slope: <code>${slopeText || "n/a"}</code>`;
cursorTooltip.style.left = screenX + 15 + "px";
cursorTooltip.style.top = screenY + 15 + "px";
cursorTooltip.classList.add("visible");
}
function hideCursorTooltip() {
cursorTooltip.classList.remove("visible");
}
function updateCursorInfoVisibility(state4) {
const cursorInfoEl = document.getElementById("cursor-info");
if (state4.cursorInfoMode === "corner") {
if (cursorInfoEl) cursorInfoEl.style.display = "";
} else {
if (cursorInfoEl) cursorInfoEl.style.display = "none";
hideCursorTooltip();
}
}
function parseHashParams() {
const hash = window.location.hash.replace(/^#/, "");
const fallback = {
center: [6.8652, 45.8326],
zoom: 12,
basemap: null,
mode: "slope+relief",
slopeOpacity: 0.45,
terrain3d: false,
terrainExaggeration: 1.4,
bearing: 0,
pitch: 0
};
if (hash.includes("=")) {
const params = new URLSearchParams(hash);
const lngRaw = Number(params.get("lng"));
const latRaw = Number(params.get("lat"));
const zoomRaw = Number(params.get("zoom"));
const basemapRaw = (params.get("basemap") || "").trim();
const modeRaw = (params.get("mode") || "").trim();
const opacityRaw = Number(params.get("opacity"));
const terrain3dRaw = parseBooleanParam(params.get("terrain"));
const terrainExaggerationRaw = Number(params.get("exaggeration"));
const bearingRaw = Number(params.get("bearing"));
const pitchRaw = Number(params.get("pitch"));
const hasLng = Number.isFinite(lngRaw) && lngRaw >= -180 && lngRaw <= 180;
const hasLat = Number.isFinite(latRaw) && latRaw >= -85.051129 && latRaw <= 85.051129;
const hasZoom = Number.isFinite(zoomRaw) && zoomRaw >= 0 && zoomRaw <= 24;
const hasOpacity = Number.isFinite(opacityRaw) && opacityRaw >= 0 && opacityRaw <= 1;
const hasTerrainExaggeration = Number.isFinite(terrainExaggerationRaw) && terrainExaggerationRaw >= 1 && terrainExaggerationRaw <= 3;
const hasBearing = Number.isFinite(bearingRaw);
const hasPitch = Number.isFinite(pitchRaw) && pitchRaw >= 0 && pitchRaw <= 85;
const validModes = /* @__PURE__ */ new Set(["", "slope+relief", "slope", "aspect", "color-relief"]);
return {
center: hasLng && hasLat ? [lngRaw, latRaw] : fallback.center,
zoom: hasZoom ? zoomRaw : fallback.zoom,
basemap: basemapRaw && BASEMAP_LAYER_GROUPS[basemapRaw] ? basemapRaw : null,
mode: validModes.has(modeRaw) ? modeRaw : fallback.mode,
slopeOpacity: hasOpacity ? opacityRaw : fallback.slopeOpacity,
terrain3d: terrain3dRaw == null ? fallback.terrain3d : terrain3dRaw,
terrainExaggeration: hasTerrainExaggeration ? terrainExaggerationRaw : fallback.terrainExaggeration,
bearing: hasBearing ? bearingRaw : fallback.bearing,
pitch: hasPitch ? pitchRaw : fallback.pitch
};
}
return fallback;
}
function syncViewToUrl(map4, state4) {
const center = map4.getCenter();
const zoom = map4.getZoom();
const bearing = map4.getBearing();
const pitch = map4.getPitch();
const params = new URLSearchParams();
params.set("lng", center.lng.toFixed(6));
params.set("lat", center.lat.toFixed(6));
params.set("zoom", zoom.toFixed(2));
params.set("basemap", state4.basemap);
params.set("mode", state4.mode);
params.set("opacity", state4.slopeOpacity.toFixed(2));
params.set("terrain", state4.terrain3d ? "1" : "0");
params.set("exaggeration", state4.terrainExaggeration.toFixed(2));
params.set("bearing", bearing.toFixed(2));
params.set("pitch", pitch.toFixed(2));
const hash = `#${params.toString()}`;
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}${hash}`);
}
function updateStatus(state4) {
document.getElementById("internalCount").textContent = String(state4.internalCount);
document.getElementById("fallbackCount").textContent = String(state4.fallbackCount);
}
function getVisibleTriplesForMap(map4) {
const z = Math.min(DEM_MAX_Z, Math.max(0, Math.floor(map4.getZoom())));
const bounds = map4.getBounds();
const north = bounds.getNorth();
const south = bounds.getSouth();
const west = bounds.getWest();
const east = bounds.getEast();
const lonRanges = west <= east ? [[west, east]] : [[west, 180], [-180, east]];
const out = [];
for (const range of lonRanges) {
const nw = lonLatToTile(range[0], north, z);
const se = lonLatToTile(range[1], south, z);
const xMin = Math.min(nw.x, se.x);
const xMax = Math.max(nw.x, se.x);
const yMin = Math.min(nw.y, se.y);
const yMax = Math.max(nw.y, se.y);
for (let y = yMin; y <= yMax; y++) {
if (y < 0 || y >= Math.pow(2, z)) continue;
for (let x = xMin; x <= xMax; x++) {
const wx = normalizeTileX(x, z);
out.push({ z, x: wx, y, key: `${z}/${wx}/${y}` });
}
}
}
return out;
}
function initSearch(map4) {
const searchBox = document.getElementById("search-box");
const searchIcon = document.getElementById("search-icon");
const searchInput = document.getElementById("search-input");
const searchResults = document.getElementById("search-results");
let searchDebounce = 0;
let searchAbort = null;
function expandSearch() {
searchBox.classList.add("expanded");
searchInput.focus();
}
function collapseSearch() {
searchBox.classList.remove("expanded");
searchInput.value = "";
searchResults.classList.remove("visible");
searchResults.innerHTML = "";
}
searchIcon.addEventListener("click", () => {
if (searchBox.classList.contains("expanded")) {
collapseSearch();
} else {
expandSearch();
}
});
searchBox.addEventListener("mouseenter", () => {
if (!searchBox.classList.contains("expanded")) expandSearch();
});
document.addEventListener("click", (e) => {
if (!searchBox.contains(e.target)) collapseSearch();
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Escape") collapseSearch();
});
async function nominatimSearch(query) {
if (searchAbort) searchAbort.abort();
searchAbort = new AbortController();
const url = `https://nominatim.openstreetmap.org/search?format=json&limit=5&q=${encodeURIComponent(query)}`;
const resp = await fetch(url, {
signal: searchAbort.signal,
headers: { "Accept-Language": navigator.language || "en" }
});
return resp.json();
}
function renderResults(results) {
searchResults.innerHTML = "";
if (!results.length) {
searchResults.classList.remove("visible");
return;
}
for (const r of results) {
const div = document.createElement("div");
div.className = "search-result";
const nameParts = r.display_name.split(", ");
const name = nameParts[0];
const detail = nameParts.slice(1, 3).join(", ");
div.innerHTML = `<div class="search-result-name">${name}</div><div class="search-result-detail">${detail}</div>`;
div.addEventListener("click", () => {
const lon = parseFloat(r.lon);
const lat = parseFloat(r.lat);
const bbox = r.boundingbox;
if (bbox) {
map4.fitBounds([[parseFloat(bbox[2]), parseFloat(bbox[0])], [parseFloat(bbox[3]), parseFloat(bbox[1])]], {
padding: 40,
maxZoom: 15,
duration: 1500
});
} else {
map4.flyTo({ center: [lon, lat], zoom: 13, duration: 1500 });
}
collapseSearch();
});
searchResults.appendChild(div);
}
searchResults.classList.add("visible");
}
searchInput.addEventListener("input", () => {
const q = searchInput.value.trim();
clearTimeout(searchDebounce);
if (q.length < 2) {
searchResults.classList.remove("visible");
searchResults.innerHTML = "";
return;
}
searchDebounce = setTimeout(async () => {
try {
const results = await nominatimSearch(q);
renderResults(results);
} catch (err) {
if (err.name !== "AbortError") console.error("Search failed:", err);
}
}, 350);
});
}
var cursorTooltip;
var init_ui = __esm({
"ui.js"() {
init_constants();
init_utils();
cursorTooltip = document.getElementById("cursor-tooltip");
}
});
// dem.js
function sampleElevationFromDEMData(dem, fx, fy) {
if (!dem || typeof dem.get !== "function" || typeof dem.dim !== "number") return null;
const dim = dem.dim;
const px = Math.max(0, Math.min(dim - 1, fx * dim));
const py = Math.max(0, Math.min(dim - 1, fy * dim));
const x0 = Math.floor(px);
const y0 = Math.floor(py);
const x1 = Math.min(x0 + 1, dim - 1);
const y1 = Math.min(y0 + 1, dim - 1);
const tx = px - x0;
const ty = py - y0;
const e00 = dem.get(x0, y0);
const e10 = dem.get(x1, y0);
const e01 = dem.get(x0, y1);
const e11 = dem.get(x1, y1);
return (1 - tx) * (1 - ty) * e00 + tx * (1 - ty) * e10 + (1 - tx) * ty * e01 + tx * ty * e11;
}
function queryLoadedElevationAtLngLat(map4, lngLat) {
const style = map4 && map4.style;
const tileManager = style && style.tileManagers && style.tileManagers[DEM_SOURCE_ID];
if (!tileManager || !tileManager.getRenderableIds || !tileManager.getTileByID) return null;
const tilesByCanonical = /* @__PURE__ */ new Map();
for (const id of tileManager.getRenderableIds()) {
const tile = tileManager.getTileByID(id);
if (!tile || !tile.dem || !tile.tileID || !tile.tileID.canonical) continue;
const c = tile.tileID.canonical;
tilesByCanonical.set(`${c.z}/${c.x}/${c.y}`, tile);
}
const lat = Math.max(-85.051129, Math.min(85.051129, lngLat.lat));
const lngWrapped = ((lngLat.lng + 180) % 360 + 360) % 360 - 180;
const rad = lat * Math.PI / 180;
const mx = Math.max(0, Math.min(1 - 1e-15, (lngWrapped + 180) / 360));
const my = Math.max(0, Math.min(1 - 1e-15, (1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2));
const maxZ = Math.min(typeof tileManager.maxzoom === "number" ? tileManager.maxzoom : DEM_MAX_Z, DEM_MAX_Z);
const minZ = Math.max(typeof tileManager.minzoom === "number" ? tileManager.minzoom : 0, 0);
for (let z = maxZ; z >= minZ; z--) {
const n = Math.pow(2, z);
const x = Math.min(Math.floor(mx * n), n - 1);
const y = Math.min(Math.floor(my * n), n - 1);
const tile = tilesByCanonical.get(`${z}/${x}/${y}`);
if (!tile || !tile.dem) continue;
const fx = mx * n - x;
const fy = my * n - y;
const elevation = sampleElevationFromDEMData(tile.dem, fx, fy);
if (elevation === null) continue;
const dim = tile.dem.dim;
const px = fx * dim, py = fy * dim;
const dx = 1;
const eL = sampleElevationFromDEMData(tile.dem, Math.max(0, px - dx) / dim, fy);
const eR = sampleElevationFromDEMData(tile.dem, Math.min(dim - 1, px + dx) / dim, fy);
const eD = sampleElevationFromDEMData(tile.dem, fx, Math.max(0, py - dx) / dim);
const eU = sampleElevationFromDEMData(tile.dem, fx, Math.min(dim - 1, py + dx) / dim);
let slopeDeg = null;
if (eL != null && eR != null && eD != null && eU != null) {
const latRad = lat * Math.PI / 180;
const cellMeters = 400750167e-1 / n / dim * Math.cos(latRad);
const dzx = (eR - eL) / (2 * cellMeters);
const dzy = (eU - eD) / (2 * cellMeters);
slopeDeg = Math.atan(Math.sqrt(dzx * dzx + dzy * dzy)) * 180 / Math.PI;
}
return { elevation, slopeDeg, tileZoom: z };
}
return null;
}
function idxPad(x, y) {
return (y + 1) * PAD_STRIDE + (x + 1);
}
function createFallbackTileRecord(z, x, y) {
return {
key: `${z}/${x}/${y}`,
z,
x,
y,
core: new Float32Array(CORE_DIM * CORE_DIM),
padded: new Float32Array(PAD_STRIDE * PAD_STRIDE),
texture: null,
loaded: false,
dirtyTexture: true
};
}
function initPaddedFromCore(tile) {
for (let y = 0; y < CORE_DIM; y++) {
for (let x = 0; x < CORE_DIM; x++) {
tile.padded[idxPad(x, y)] = tile.core[y * CORE_DIM + x];
}
}
for (let x = 0; x < CORE_DIM; x++) {
tile.padded[idxPad(x, -1)] = tile.padded[idxPad(x, 0)];
tile.padded[idxPad(x, CORE_DIM)] = tile.padded[idxPad(x, CORE_DIM - 1)];
}
for (let y = 0; y < CORE_DIM; y++) {
tile.padded[idxPad(-1, y)] = tile.padded[idxPad(0, y)];
tile.padded[idxPad(CORE_DIM, y)] = tile.padded[idxPad(CORE_DIM - 1, y)];
}
tile.padded[idxPad(-1, -1)] = tile.padded[idxPad(0, 0)];
tile.padded[idxPad(CORE_DIM, -1)] = tile.padded[idxPad(CORE_DIM - 1, 0)];
tile.padded[idxPad(-1, CORE_DIM)] = tile.padded[idxPad(0, CORE_DIM - 1)];
tile.padded[idxPad(CORE_DIM, CORE_DIM)] = tile.padded[idxPad(CORE_DIM - 1, CORE_DIM - 1)];
tile.dirtyTexture = true;
}
function adjustedDx(aTile, bTile) {
const world = Math.pow(2, aTile.z);
let dx = bTile.x - aTile.x;
if (Math.abs(dx) > 1) {
if (Math.abs(dx + world) === 1) dx += world;
else if (Math.abs(dx - world) === 1) dx -= world;
}
return dx;
}
function backfillBorder(dstTile, srcTile, dx, dy) {
let xMin = dx * CORE_DIM;
let xMax = dx * CORE_DIM + CORE_DIM;
let yMin = dy * CORE_DIM;
let yMax = dy * CORE_DIM + CORE_DIM;
if (dx === -1) xMin = xMax - 1;
if (dx === 1) xMax = xMin + 1;
if (dy === -1) yMin = yMax - 1;
if (dy === 1) yMax = yMin + 1;
const ox = -dx * CORE_DIM;
const oy = -dy * CORE_DIM;
for (let y = yMin; y < yMax; y++) {
for (let x = xMin; x < xMax; x++) {
dstTile.padded[idxPad(x, y)] = srcTile.padded[idxPad(x + ox, y + oy)];
}
}
dstTile.dirtyTexture = true;
}
function uploadPaddedTexture(gl, tile) {
if (!tile.loaded || !tile.dirtyTexture) return;
const bytes = new Uint8Array(PAD_STRIDE * PAD_STRIDE * 4);
for (let i = 0; i < tile.padded.length; i++) {
encodeTerrarium(tile.padded[i], bytes, i * 4);
}
if (!tile.texture) tile.texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tile.texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, PAD_STRIDE, PAD_STRIDE, 0, gl.RGBA, gl.UNSIGNED_BYTE, bytes);
tile.dirtyTexture = false;
}
function createHybridBorderLayer(state4, getVisibleTriplesForMap2, updateStatus2) {
return {
id: "dem-analysis-hybrid-border",
type: "custom",
renderingMode: "2d",
map: null,
program: null,
buffer: null,
aPos: -1,
aUv: -1,
uMatrix: null,
uDem: null,
uOpacity: null,
uMode: null,
uZoom: null,
uLatRange: null,
uTileSize: null,
uTexel: null,
uUvOffset: null,
uUvScale: null,
internalTextures: /* @__PURE__ */ new Map(),
fallbackTiles: /* @__PURE__ */ new Map(),
fallbackInFlight: /* @__PURE__ */ new Set(),
onAdd(map4, gl) {
this.map = map4;
const vertexSource = `
precision highp float;
uniform mat4 u_matrix;
attribute vec2 a_pos;
attribute vec2 a_uv;
varying vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = u_matrix * vec4(a_pos, 0.0, 1.0);
}
`;
const fragmentSource = `
precision highp float;
uniform sampler2D u_dem;
uniform float u_opacity;
uniform int u_mode;
uniform float u_zoom;
uniform vec2 u_latrange;
uniform float u_tile_size;
uniform vec2 u_texel;
uniform float u_uv_offset;
uniform float u_uv_scale;
uniform int u_step_count;
uniform float u_step_values[${MAX_STEP_STOPS}];
uniform vec3 u_step_colors[${MAX_STEP_STOPS + 1}];
varying vec2 v_uv;
float decodeTerrarium(vec4 c) {
float r = c.r * 255.0;
float g = c.g * 255.0;
float b = c.b * 255.0;
return (r * 256.0 + g + b / 256.0) - 32768.0;
}
vec2 paddedUV(vec2 uv) {
return uv * u_uv_scale + vec2(u_uv_offset);
}
float elevationAt(vec2 uv) {
return decodeTerrarium(texture2D(u_dem, clamp(uv, vec2(0.0), vec2(1.0))));
}
vec2 coreUVForDeriv(vec2 uvCore) {
vec2 tileCoord = floor(clamp(uvCore, vec2(0.0), vec2(1.0)) * u_tile_size);
tileCoord = clamp(tileCoord, vec2(0.0), vec2(u_tile_size - 1.0));
return (tileCoord + vec2(0.5)) / u_tile_size;
}
vec2 hornDeriv(vec2 uvCore) {
vec2 uv = paddedUV(uvCore);
vec2 e = u_texel;
float a = elevationAt(uv + vec2(-e.x, -e.y));
float b = elevationAt(uv + vec2(0.0, -e.y));
float c = elevationAt(uv + vec2(e.x, -e.y));
float d = elevationAt(uv + vec2(-e.x, 0.0));
float f = elevationAt(uv + vec2(e.x, 0.0));
float g = elevationAt(uv + vec2(-e.x, e.y));
float h = elevationAt(uv + vec2(0.0, e.y));
float i = elevationAt(uv + vec2(e.x, e.y));
float dzdx = (c + 2.0 * f + i) - (a + 2.0 * d + g);
float dzdy = (g + 2.0 * h + i) - (a + 2.0 * b + c);
vec2 deriv = vec2(dzdx, dzdy) * u_tile_size / pow(2.0, 28.2562 - u_zoom);
float lat = (u_latrange.x - u_latrange.y) * (1.0 - uvCore.y) + u_latrange.y;
deriv /= max(cos(radians(lat)), 0.0001);
return deriv;
}
vec3 colorFromStep(float value) {
vec3 color = u_step_colors[0];
for (int i = 0; i < ${MAX_STEP_STOPS}; i++) {
if (i >= u_step_count) break;
if (value >= u_step_values[i]) {
color = u_step_colors[i + 1];
}
}
return color;
}
void main() {
vec2 d = hornDeriv(coreUVForDeriv(v_uv));
float gradient = length(d);
float slopeDeg = clamp(degrees(atan(gradient)), 0.0, 90.0);
float aspectDeg = degrees(atan(d.y, -d.x));
aspectDeg = mod(90.0 - aspectDeg, 360.0);
if (gradient < 0.0001) aspectDeg = 0.0;
float scalar = (u_mode == 0) ? slopeDeg : aspectDeg;
vec3 color = colorFromStep(scalar);
gl_FragColor = vec4(color * u_opacity, u_opacity);
}
`;
function compile(gl2, type, src) {
const shader = gl2.createShader(type);
gl2.shaderSource(shader, src);
gl2.compileShader(shader);
if (!gl2.getShaderParameter(shader, gl2.COMPILE_STATUS)) {
throw new Error(gl2.getShaderInfoLog(shader) || "Shader compile failed");
}
return shader;
}
const vs = compile(gl, gl.VERTEX_SHADER, vertexSource);
const fs = compile(gl, gl.FRAGMENT_SHADER, fragmentSource);
this.program = gl.createProgram();
gl.attachShader(this.program, vs);
gl.attachShader(this.program, fs);
gl.linkProgram(this.program);
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(this.program) || "Program link failed");
}
this.aPos = gl.getAttribLocation(this.program, "a_pos");
this.aUv = gl.getAttribLocation(this.program, "a_uv");
this.uMatrix = gl.getUniformLocation(this.program, "u_matrix");
this.uDem = gl.getUniformLocation(this.program, "u_dem");
this.uOpacity = gl.getUniformLocation(this.program, "u_opacity");
this.uMode = gl.getUniformLocation(this.program, "u_mode");
this.uZoom = gl.getUniformLocation(this.program, "u_zoom");
this.uLatRange = gl.getUniformLocation(this.program, "u_latrange");
this.uTileSize = gl.getUniformLocation(this.program, "u_tile_size");
this.uTexel = gl.getUniformLocation(this.program, "u_texel");
this.uUvOffset = gl.getUniformLocation(this.program, "u_uv_offset");
this.uUvScale = gl.getUniformLocation(this.program, "u_uv_scale");
this.uStepCount = gl.getUniformLocation(this.program, "u_step_count");
this.uStepValues = gl.getUniformLocation(this.program, "u_step_values");
this.uStepColors = gl.getUniformLocation(this.program, "u_step_colors");
this.buffer = gl.createBuffer();
},
getDemTileManager() {
const style = this.map && this.map.style;
if (!style || !style.tileManagers) return null;
return style.tileManagers[DEM_SOURCE_ID] || null;
},
getVisibleTriples() {
return getVisibleTriplesForMap2(this.map);
},
updateInternalTexture(gl, internalTile) {
if (!internalTile.dem || !internalTile.dem.getPixels || typeof internalTile.dem.stride !== "number") return null;
const uid = String(internalTile.dem.uid);
const cacheKey = `dem:${uid}`;
const stride = internalTile.dem.stride;
const cached = this.internalTextures.get(cacheKey);
if (cached) return cached;
const pixels = internalTile.dem.getPixels();
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, stride, stride, 0, gl.RGBA, gl.UNSIGNED_BYTE, pixels.data);
const entry = {
texture: tex,
stride
};
this.internalTextures.set(cacheKey, entry);
return entry;
},
async ensureFallbackTile(v, gl) {
if (!this.fallbackTiles.has(v.key)) {
this.fallbackTiles.set(v.key, createFallbackTileRecord(v.z, v.x, v.y));
}
const tile = this.fallbackTiles.get(v.key);
if (tile.loaded || this.fallbackInFlight.has(v.key)) return tile;
this.fallbackInFlight.add(v.key);
try {
const url = demTileUrl(v.z, v.x, v.y);
const response = await fetch(url, { mode: "cors", cache: "force-cache" });
if (!response.ok) throw new Error(`DEM tile ${v.key}: HTTP ${response.status}`);
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
const canvas = document.createElement("canvas");
canvas.width = CORE_DIM;
canvas.height = CORE_DIM;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.drawImage(bitmap, 0, 0, CORE_DIM, CORE_DIM);
const bytes = ctx.getImageData(0, 0, CORE_DIM, CORE_DIM).data;
for (let i = 0; i < CORE_DIM * CORE_DIM; i++) {
const bi = i * 4;
tile.core[i] = decodeTerrarium(bytes[bi], bytes[bi + 1], bytes[bi + 2]);
}
initPaddedFromCore(tile);
tile.loaded = true;
for (let dy = -1; dy <= 1; dy++) {
for (let dx0 = -1; dx0 <= 1; dx0++) {
if (dx0 === 0 && dy === 0) continue;
const nx = normalizeTileX(tile.x + dx0, tile.z);
const ny = tile.y + dy;
if (ny < 0 || ny >= Math.pow(2, tile.z)) continue;
const nKey = `${tile.z}/${nx}/${ny}`;
const neighbor = this.fallbackTiles.get(nKey);
if (!neighbor || !neighbor.loaded) continue;
const dx = adjustedDx(tile, neighbor);
const ddy = neighbor.y - tile.y;
if (Math.abs(dx) > 1 || Math.abs(ddy) > 1 || dx === 0 && ddy === 0) continue;
backfillBorder(tile, neighbor, dx, ddy);
backfillBorder(neighbor, tile, -dx, -ddy);
}
}
uploadPaddedTexture(gl, tile);
this.map.triggerRepaint();
} catch (err) {
console.error("Fallback DEM fetch failed:", v.key, err);
} finally {
this.fallbackInFlight.delete(v.key);
}
return tile;
},
render(gl, args) {
if (!state4.mode || state4.mode === "color-relief" || state4.effectiveSlopeOpacity <= 0) {
state4.internalCount = 0;
state4.fallbackCount = 0;
updateStatus2();
return;
}
state4.internalCount = 0;
state4.fallbackCount = 0;
gl.useProgram(this.program);
gl.uniformMatrix4fv(this.uMatrix, false, args.defaultProjectionData.mainMatrix);
gl.uniform1f(this.uOpacity, state4.effectiveSlopeOpacity);
const renderAsSlope = state4.mode === "slope" || state4.mode === "slope+relief";
gl.uniform1i(this.uMode, renderAsSlope ? 0 : 1);
const ramp = renderAsSlope ? PARSED_RAMPS.slope : PARSED_RAMPS.aspect;
gl.uniform1i(this.uStepCount, ramp.stepCount);
gl.uniform1fv(this.uStepValues, ramp.values);
gl.uniform3fv(this.uStepColors, ramp.colors);
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
gl.enableVertexAttribArray(this.aPos);
gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, 16, 0);
gl.enableVertexAttribArray(this.aUv);
gl.vertexAttribPointer(this.aUv, 2, gl.FLOAT, false, 16, 8);
gl.enable(gl.BLEND);
if (state4.multiplyBlend) {
gl.blendFunc(gl.DST_COLOR, gl.ONE_MINUS_SRC_ALPHA);
} else {
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
}
const tileManager = this.getDemTileManager();
const visible = this.getVisibleTriples();
const internalByCanonical = /* @__PURE__ */ new Map();
if (tileManager && tileManager.getRenderableIds && tileManager.getTileByID) {
const ids = tileManager.getRenderableIds();
for (const id of ids) {
const tile = tileManager.getTileByID(id);
if (!tile || !tile.tileID || !tile.tileID.canonical) continue;
const cz = tile.tileID.canonical.z;
const cx = tile.tileID.canonical.x;
const cy = tile.tileID.canonical.y;
internalByCanonical.set(`${cz}/${cx}/${cy}`, tile);
}
}
for (const v of visible) {
const tileBounds = tileToLngLatBounds(v.x, v.y, v.z);
const canonicalKey = `${v.z}/${v.x}/${v.y}`;
let texture = null;
let stride = PAD_STRIDE;
let wrap = 0;
const internalTile = internalByCanonical.get(canonicalKey);
if (internalTile) {
const internalTex = this.updateInternalTexture(gl, internalTile);
if (internalTex) {
texture = internalTex.texture;
stride = internalTex.stride;
wrap = internalTile.tileID && typeof internalTile.tileID.wrap === "number" ? internalTile.tileID.wrap : 0;
state4.internalCount += 1;
}
}
if (!texture) {
this.ensureFallbackTile(v, gl);
const fallback = this.fallbackTiles.get(v.key);
if (!fallback || !fallback.loaded) continue;
uploadPaddedTexture(gl, fallback);
if (!fallback.texture) continue;
texture = fallback.texture;
stride = PAD_STRIDE;
wrap = 0;
state4.fallbackCount += 1;
}
const verts = mercatorVertsForTile(v.z, v.x, v.y, wrap);
gl.uniform1f(this.uZoom, v.z);
gl.uniform2f(this.uLatRange, tileBounds.north, tileBounds.south);
gl.uniform1f(this.uTileSize, stride - 2);
gl.uniform2f(this.uTexel, 1 / stride, 1 / stride);
gl.uniform1f(this.uUvOffset, 1 / stride);
gl.uniform1f(this.uUvScale, (stride - 2) / stride);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(this.uDem, 0);
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STREAM_DRAW);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
updateStatus2();
},
onRemove(_map, gl) {
for (const entry of this.internalTextures.values()) {
if (entry.texture) gl.deleteTexture(entry.texture);
}
for (const tile of this.fallbackTiles.values()) {
if (tile.texture) gl.deleteTexture(tile.texture);
}
this.internalTextures.clear();
this.fallbackTiles.clear();
this.fallbackInFlight.clear();
if (this.buffer) gl.deleteBuffer(this.buffer);
if (this.program) gl.deleteProgram(this.program);
}
};
}
var init_dem = __esm({
"dem.js"() {
init_utils();
init_constants();
}
});
// track-edit.js
function showToast(msg, durationMs) {
toastEl.textContent = msg;
toastEl.classList.add("visible");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("visible"), durationMs || 2500);
}
function isTrackEditing(tId) {
return tId != null && tId === editingTrackId;
}
function elevationAt(lngLat) {
return tracksFns.elevationAt(lngLat);
}
function updateInsertPopup() {
const t = editingTrackId ? tracksFns.findTrack(editingTrackId) : null;
if (!t || selectedVertexIndex == null || selectedVertexIndex >= t.coords.length) {
removeInsertPopup();
return;
}
const coord = t.coords[selectedVertexIndex];
const lngLat = [coord[0], coord[1]];
if (!insertPopupMarker) {
const el = document.createElement("div");
el.className = "vertex-insert-popup";
el.innerHTML = '<button class="insert-popup-btn" title="Insert points after this vertex">+</button>';
el.querySelector(".insert-popup-btn").addEventListener("click", (e) => {
e.stopPropagation();
if (insertAfterIdx != null) {
insertAfterIdx = null;
} else if (selectedVertexIndex != null) {
insertAfterIdx = selectedVertexIndex;
}
syncUndoBtn();
});
insertPopupMarker = new maplibregl.Marker({ element: el, anchor: "left", offset: [8, 0] }).setLngLat(lngLat).addTo(map);
} else {
insertPopupMarker.setLngLat(lngLat);
}
const btn = insertPopupMarker.getElement().querySelector(".insert-popup-btn");
if (btn) btn.classList.toggle("active", insertAfterIdx != null);
}
function removeInsertPopup() {
if (insertPopupMarker) {
insertPopupMarker.remove();
insertPopupMarker = null;
}
}
function clearHoverInsertMarker() {
hoverInsertInfo = null;
const src = map.getSource(HOVER_INSERT_SOURCE_ID);
if (src) src.setData({ type: "FeatureCollection", features: [] });
}
function showHoverInsertMarker(lngLat) {
const src = map.getSource(HOVER_INSERT_SOURCE_ID);
if (src) {
src.setData({ type: "FeatureCollection", features: [{
type: "Feature",
geometry: { type: "Point", coordinates: [lngLat.lng, lngLat.lat] },
properties: {}
}] });
}
}
function updateInsertPreview() {
const src = map.getSource("insert-preview-line");
if (!src) return;
const t = editingTrackId ? tracksFns.findTrack(editingTrackId) : null;
if (!t || !t.coords.length) {
src.setData({ type: "FeatureCollection", features: [] });
return;
}
let target = insertPreviewLngLat;
if (mobileFriendlyMode && editingTrackId) {
const center = map.getCenter();
target = { lng: center.lng, lat: center.lat };
}
if (!target) {
src.setData({ type: "FeatureCollection", features: [] });
return;
}
const features = [];
const tCoord = [target.lng, target.lat];
if (insertAfterIdx != null && insertAfterIdx < t.coords.length) {
const prev = t.coords[insertAfterIdx];
features.push({
type: "Feature",
geometry: { type: "LineString", coordinates: [[prev[0], prev[1]], tCoord] },
properties: {}
});
if (insertAfterIdx + 1 < t.coords.length) {
const next = t.coords[insertAfterIdx + 1];
features.push({
type: "Feature",
geometry: { type: "LineString", coordinates: [tCoord, [next[0], next[1]]] },
properties: {}
});
}
} else if (t.coords.length > 0) {
const last = t.coords[t.coords.length - 1];
features.push({
type: "Feature",
geometry: { type: "LineString", coordinates: [[last[0], last[1]], tCoord] },
properties: {}
});
}
src.setData({ type: "FeatureCollection", features });
}
function findClosestPointOnTrack(t, mousePoint) {
if (!t || t.coords.length < 2) return null;
let bestDist = Infinity;
let bestLngLat = null;
let bestSegment = -1;
for (let i = 0; i < t.coords.length - 1; i++) {
const a = map.project([t.coords[i][0], t.coords[i][1]]);
const b = map.project([t.coords[i + 1][0], t.coords[i + 1][1]]);
const dx = b.x - a.x, dy = b.y - a.y;
const len2 = dx * dx + dy * dy;
let tp = 0;
if (len2 > 0) {
tp = ((mousePoint.x - a.x) * dx + (mousePoint.y - a.y) * dy) / len2;
tp = Math.max(0, Math.min(1, tp));
}
if (tp < 0.1 || tp > 0.9) continue;
const px = a.x + tp * dx, py = a.y + tp * dy;
const dist = Math.sqrt((mousePoint.x - px) ** 2 + (mousePoint.y - py) ** 2);
if (dist < bestDist) {
bestDist = dist;
bestLngLat = map.unproject([px, py]);
bestSegment = i;
}
}
if (bestDist < 20 && bestSegment >= 0) {
return { lngLat: bestLngLat, insertAfter: bestSegment, distance: bestDist };
}
return null;
}
function hitTestVertex(point) {
const tId = editingTrackId;
if (!tId) return null;
const t = tracksFns.findTrack(tId);
if (!t) return null;
const layerId = tracksFns.trackPtsLayerId(t);
if (!map.getLayer(layerId)) return null;
const r = 12;
const features = map.queryRenderedFeatures(
[[point.x - r, point.y - r], [point.x + r, point.y + r]],
{ layers: [layerId] }
);
if (!features.length) return null;
const real = features.find((f) => f.properties.idx != null);
if (real) return { trackId: t.id, index: real.properties.idx };
return null;
}
function syncUndoBtn() {
if (!tracksFns.getActiveTrack) return;
const t = tracksFns.getActiveTrack();
const show = t && t.coords.length > 0 && isTrackEditing(t.id);
undoBtn.style.display = show ? "" : "none";
mobileModeBtn.style.display = (isMobile || isLocalhost) && show ? "" : "none";
mobileModeBtn.classList.toggle("active", mobileFriendlyMode);
tracksFns.updateVertexHighlight(editingTrackId, selectedVertexIndex);
updateInsertPopup();
updateInsertPreview();
}
function setDefaultMapCursor() {
if (!editingTrackId) map.getCanvas().style.cursor = "cell";
}
function enterEditMode(tId) {
if (editingTrackId && editingTrackId !== tId) exitEditMode();
editingTrackId = tId;
selectedVertexIndex = null;
insertAfterIdx = null;
map.getCanvas().style.cursor = "crosshair";
drawBtn.classList.add("active");
tracksFns.updateVertexHighlight(editingTrackId, selectedVertexIndex);
tracksFns.renderTrackList();
syncUndoBtn();
if (mobileFriendlyMode) {
drawCrosshair.classList.add("visible");
showToast("Tap anywhere to add a point at center", 3e3);
}
}
function exitEditMode() {
const wasNewTrack = editingIsNewTrack;
editingIsNewTrack = false;
selectedVertexIndex = null;
insertAfterIdx = null;
removeInsertPopup();
drawBtn.classList.remove("active");
setDefaultMapCursor();
drawCrosshair.classList.remove("visible");
if (mobileSelectedVertex) cancelMobileMove();
const t = editingTrackId ? tracksFns.findTrack(editingTrackId) : null;
if (wasNewTrack && t && t.coords.length < 2) {
tracksFns.removeIncompleteNewTrack(t);
} else if (t) {
updateProfileFn();
}
editingTrackId = null;
clearHoverInsertMarker();
tracksFns.updateVertexHighlight(editingTrackId, selectedVertexIndex);
tracksFns.renderTrackList();
syncUndoBtn();
}
function startNewTrack() {
if (editingTrackId) exitEditMode();
editingIsNewTrack = true;
const t = tracksFns.createNewTrack();
enterEditMode(t.id);
}
function cancelMobileMove() {
mobileSelectedVertex = null;
mobileHint.classList.remove("visible");
map.dragPan.enable();
const t = tracksFns.getActiveTrack();
if (t) {
tracksFns.renderTrackList();
updateProfileFn();
syncUndoBtn();
}
}
function getEditState() {
return {
get editingTrackId() {
return editingTrackId;
},
get editingIsNewTrack() {
return editingIsNewTrack;
},
get selectedVertexIndex() {
return selectedVertexIndex;
},
get insertAfterIdx() {
return insertAfterIdx;
},
get mobileFriendlyMode() {
return mobileFriendlyMode;
},
isTrackEditing,
enterEditMode,
exitEditMode,
startNewTrack,
syncUndoBtn
};
}
function initTrackEdit(mapRef, stateRef, updateProfile2, fns) {
map = mapRef;
state = stateRef;
updateProfileFn = updateProfile2;
tracksFns = fns;
drawBtn = document.getElementById("draw-btn");
undoBtn = document.getElementById("undo-btn");
mobileModeBtn = document.getElementById("mobile-mode-btn");
mobileHint = document.getElementById("mobile-move-hint");
toastEl = document.getElementById("toast");
drawCrosshair = document.getElementById("draw-crosshair");
drawBtn.addEventListener("click", () => {
if (editingTrackId) exitEditMode();
else startNewTrack();
});
undoBtn.addEventListener("click", () => {
const t = tracksFns.getActiveTrack();
if (!t || !t.coords.length) return;
t.coords.pop();
if (selectedVertexIndex != null && selectedVertexIndex >= t.coords.length) {
selectedVertexIndex = t.coords.length > 0 ? t.coords.length - 1 : null;
}
if (insertAfterIdx != null && insertAfterIdx >= t.coords.length) {
insertAfterIdx = t.coords.length > 0 ? t.coords.length - 1 : null;
}
tracksFns.onTrackCoordsChanged(t);
syncUndoBtn();
});
mobileModeBtn.addEventListener("click", () => {
mobileFriendlyMode = !mobileFriendlyMode;
mobileModeBtn.classList.toggle("active", mobileFriendlyMode);
if (mobileFriendlyMode && editingTrackId) {
drawCrosshair.classList.add("visible");
showToast("Tap anywhere to add a point at center", 3e3);
} else {
drawCrosshair.classList.remove("visible");
if (mobileSelectedVertex) cancelMobileMove();
}
syncUndoBtn();
});
map.on("click", (e) => {
if (suppressNextMapClick) {
suppressNextMapClick = false;
return;
}
if (editingTrackId) {
const t = tracksFns.findTrack(editingTrackId);
if (!t) return;
if (e.originalEvent.shiftKey || e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
const hit = hitTestVertex(e.point);
if (hit && hit.index != null) {
t.coords.splice(hit.index, 1);
if (selectedVertexIndex != null) {
if (hit.index < selectedVertexIndex) selectedVertexIndex--;
else if (hit.index === selectedVertexIndex) selectedVertexIndex = null;
}
if (insertAfterIdx != null) {
if (hit.index <= insertAfterIdx) insertAfterIdx = Math.max(0, insertAfterIdx - 1);
}
tracksFns.onTrackCoordsChanged(t);
if (t.coords.length === 0) tracksFns.deleteTrack(t.id);
}
return;
}
const hitPt = hitTestVertex(e.point);
if (hitPt && hitPt.index != null) {
if (mobileFriendlyMode) {
selectedVertexIndex = hitPt.index;
mobileSelectedVertex = hitPt;
mobileHint.textContent = "Drag screen to move point \xB7 tap elsewhere to deselect";
mobileHint.classList.add("visible");
map.dragPan.disable();
showToast("Drag screen to move", 2e3);
syncUndoBtn();
return;
}
if (selectedVertexIndex === hitPt.index) {
selectedVertexIndex = null;
insertAfterIdx = null;
} else {
selectedVertexIndex = hitPt.index;
if (insertAfterIdx != null) insertAfterIdx = hitPt.index;
}
syncUndoBtn();
return;
}
if (mobileFriendlyMode && mobileSelectedVertex) {
cancelMobileMove();
}
let insertLngLat = e.lngLat;
if (mobileFriendlyMode) {
insertLngLat = map.getCenter();
}
const ele = elevationAt(insertLngLat);
if (insertAfterIdx != null) {
t.coords.splice(insertAfterIdx + 1, 0, [insertLngLat.lng, insertLngLat.lat, ele]);
insertAfterIdx++;
selectedVertexIndex = insertAfterIdx;
} else {
t.coords.push([insertLngLat.lng, insertLngLat.lat, ele]);
}
tracksFns.onTrackCoordsChanged(t);
syncUndoBtn();
return;
}
});
setDefaultMapCursor();
map.on("dblclick", (e) => {
if (editingTrackId) {
e.preventDefault();
exitEditMode();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && editingTrackId) exitEditMode();
if (e.key === "Escape" && mobileSelectedVertex) cancelMobileMove();
if ((e.ctrlKey || e.metaKey) && e.key === "z" && isTrackEditing(tracksFns.getActiveTrack()?.id)) {
const t = tracksFns.getActiveTrack();
if (t && t.coords.length > 0) {
e.preventDefault();
t.coords.pop();
if (selectedVertexIndex != null && selectedVertexIndex >= t.coords.length) {
selectedVertexIndex = t.coords.length > 0 ? t.coords.length - 1 : null;
}
if (insertAfterIdx != null && insertAfterIdx >= t.coords.length) {
insertAfterIdx = t.coords.length > 0 ? t.coords.length - 1 : null;
}
tracksFns.onTrackCoordsChanged(t);
syncUndoBtn();
}
}
});
if (!isMobile) {
let finishVertexDrag = function() {
if (!dragVertexInfo) return;
const t = tracksFns.findTrack(dragVertexInfo.trackId);
if (!dragMoved && t) {
selectedVertexIndex = dragVertexInfo.index;
if (insertAfterIdx != null) insertAfterIdx = dragVertexInfo.index;
}
dragVertexInfo = null;
hoverInsertDrag = false;
map.dragPan.enable();
hoveredVertex = false;
if (dragMoved) suppressNextMapClick = true;
dragMoved = false;
setDefaultMapCursor();
if (t) {
tracksFns.onTrackCoordsChanged(t);
syncUndoBtn();
}
};
let hoveredVertex = false;
let dragMoved = false;
let hoverInsertDrag = false;
map.on("mousedown", (e) => {
if (!editingTrackId) return;
const hit = hitTestVertex(e.point);
if (hit && hit.index != null) {
e.preventDefault();
e.originalEvent.stopPropagation();
dragVertexInfo = hit;
dragMoved = false;
map.dragPan.disable();
map.getCanvas().style.cursor = "grabbing";
return;
}
if (hoverInsertInfo) {
const hiPt = map.project([hoverInsertInfo.lngLat.lng, hoverInsertInfo.lngLat.lat]);
const dist = Math.sqrt((e.point.x - hiPt.x) ** 2 + (e.point.y - hiPt.y) ** 2);
if (dist < 20) {
const t = tracksFns.findTrack(editingTrackId);
if (t) {
const ele = elevationAt(hoverInsertInfo.lngLat);
t.coords.splice(
hoverInsertInfo.insertAfter + 1,
0,
[hoverInsertInfo.lngLat.lng, hoverInsertInfo.lngLat.lat, ele]
);
tracksFns.invalidateAndRefresh(t);
e.preventDefault();
e.originalEvent.stopPropagation();
dragVertexInfo = { trackId: t.id, index: hoverInsertInfo.insertAfter + 1 };
hoverInsertDrag = true;
dragMoved = false;
clearHoverInsertMarker();
map.dragPan.disable();
map.getCanvas().style.cursor = "grabbing";
return;
}
}
}
});
map.on("mousemove", (e) => {
if (dragVertexInfo) {
const t = tracksFns.findTrack(dragVertexInfo.trackId);
if (!t) return;
const c = t.coords[dragVertexInfo.index];
c[0] = e.lngLat.lng;
c[1] = e.lngLat.lat;
c[2] = elevationAt(e.lngLat);
dragMoved = true;
tracksFns.refreshTrackSource(t);
return;
}
if (editingTrackId) {
if (!mobileFriendlyMode) {
insertPreviewLngLat = e.lngLat;
updateInsertPreview();
}
const hit = hitTestVertex(e.point);
const isRealVertex = Boolean(hit && hit.index != null);
if (isRealVertex && !hoveredVertex) {
hoveredVertex = true;
clearHoverInsertMarker();
map.getCanvas().style.cursor = "grab";
} else if (!isRealVertex && hoveredVertex) {
hoveredVertex = false;
map.getCanvas().style.cursor = "crosshair";
}
if (!isRealVertex && !hoveredVertex) {
const t = tracksFns.findTrack(editingTrackId);
const closest = findClosestPointOnTrack(t, e.point);
if (closest) {
hoverInsertInfo = closest;
showHoverInsertMarker(closest.lngLat);
map.getCanvas().style.cursor = "copy";
} else {
if (hoverInsertInfo) {
clearHoverInsertMarker();
map.getCanvas().style.cursor = "crosshair";
}
}
} else if (hoverInsertInfo) {
clearHoverInsertMarker();
}
}
});
map.on("mouseup", () => {
finishVertexDrag();
});
window.addEventListener("mouseup", finishVertexDrag);
}
if (isMobile) {
let touchLongPressTimer = null;
let touchStartPt = null;
let mobileDragVertex = null;
map.getCanvas().addEventListener("touchstart", (e) => {
if (!editingTrackId || mobileFriendlyMode) return;
if (e.touches.length !== 1) return;
const touch = e.touches[0];
const rect = map.getCanvas().getBoundingClientRect();
const pt = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
touchStartPt = pt;
const hit = hitTestVertex(pt);
if (hit && hit.index != null) {
touchLongPressTimer = setTimeout(() => {
mobileDragVertex = { ...hit, screenX: touch.clientX, screenY: touch.clientY };
map.dragPan.disable();
showToast("Drag to move point", 1500);
}, 400);
}
}, { passive: true });
map.getCanvas().addEventListener("touchmove", (e) => {
if (touchLongPressTimer && touchStartPt) {
const touch = e.touches[0];
const rect = map.getCanvas().getBoundingClientRect();
const dx = touch.clientX - rect.left - touchStartPt.x;
const dy = touch.clientY - rect.top - touchStartPt.y;
if (Math.sqrt(dx * dx + dy * dy) > 10) {
clearTimeout(touchLongPressTimer);
touchLongPressTimer = null;
}
}
if (mobileDragVertex && e.touches.length === 1) {
const touch = e.touches[0];
const rect = map.getCanvas().getBoundingClientRect();
const pt = { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
const lngLat = map.unproject([pt.x, pt.y]);
const t = tracksFns.findTrack(mobileDragVertex.trackId);
if (t) {
const c = t.coords[mobileDragVertex.index];
c[0] = lngLat.lng;
c[1] = lngLat.lat;
c[2] = elevationAt(lngLat);
tracksFns.invalidateAndRefresh(t);
}
e.preventDefault();
}
}, { passive: false });
map.getCanvas().addEventListener("touchend", () => {
if (touchLongPressTimer) {
clearTimeout(touchLongPressTimer);
touchLongPressTimer = null;
}
if (mobileDragVertex) {
const t = tracksFns.findTrack(mobileDragVertex.trackId);
mobileDragVertex = null;
map.dragPan.enable();
if (t) {
tracksFns.onTrackCoordsChanged(t);
syncUndoBtn();
}
}
touchStartPt = null;
}, { passive: true });
map.on("move", () => {
if (!mobileSelectedVertex) return;
const t = tracksFns.findTrack(mobileSelectedVertex.trackId);
if (!t) return;
const center = map.getCenter();
const c = t.coords[mobileSelectedVertex.index];
c[0] = center.lng;
c[1] = center.lat;
c[2] = elevationAt(center);
tracksFns.invalidateAndRefresh(t);
});
map.on("touchend", () => {
if (!mobileSelectedVertex) return;
cancelMobileMove();
});
}
map.on("move", () => {
if (mobileFriendlyMode && editingTrackId) {
updateInsertPreview();
}
});
}
var map, state, updateProfileFn, tracksFns, editingTrackId, editingIsNewTrack, dragVertexInfo, mobileSelectedVertex, suppressNextMapClick, hoverInsertInfo, selectedVertexIndex, insertAfterIdx, insertPopupMarker, insertPreviewLngLat, isMobile, isLocalhost, mobileFriendlyMode, drawBtn, undoBtn, mobileModeBtn, mobileHint, drawCrosshair, toastEl, toastTimer, HOVER_INSERT_SOURCE_ID;
var init_track_edit = __esm({
"track-edit.js"() {
init_ui();
updateProfileFn = () => {
};
tracksFns = {};
editingTrackId = null;
editingIsNewTrack = false;
dragVertexInfo = null;
mobileSelectedVertex = null;
suppressNextMapClick = false;
hoverInsertInfo = null;
selectedVertexIndex = null;
insertAfterIdx = null;
insertPopupMarker = null;
insertPreviewLngLat = null;
isMobile = "ontouchstart" in window || navigator.maxTouchPoints > 0;
isLocalhost = location.hostname === "localhost" || location.hostname === "127.0.0.1";
mobileFriendlyMode = isMobile;
toastTimer = 0;
HOVER_INSERT_SOURCE_ID = "hover-insert-point";
}
});
// node_modules/@we-gold/gpxjs/dist/gpxjs.js
function stringifyGPX(gpx, customXmlSerializer) {
const doc = gpx.xml.implementation.createDocument(GPX_NS, "gpx");
return doc.documentElement.setAttribute("version", "1.1"), doc.documentElement.setAttribute("creator", "gpxjs"), new XmlMapper(doc).mapObject(GPX_MAPPING, gpx, doc.documentElement), (customXmlSerializer ?? new XMLSerializer()).serializeToString(doc);
}
function ExtensionsMapping(doc, srcObj, dstElem) {
for (const key in srcObj) {
const elem = doc.createElementNS(GPX_NS, key);
dstElem.append(elem);
const value = srcObj[key];
if (typeof value == "object")
ExtensionsMapping(doc, value, elem);
else {
const node = doc.createTextNode(value.toString());
elem.append(node);
}
}
}
var __defProp, __defNormalProp, __name, __publicField, DEFAULT_OPTIONS, calculateDistance, haversineDistance, calculateDuration, calculateElevation, calculateSlopes, parseGPX, parseGPXWithCustomParser, parseExtensions, getElementValue, querySelectDirectDescendant, deleteNullFields, _ParsedGPX, ParsedGPX, GPX_NS, EXPR_PROPERTY, FOR_PROPERTY, FUNC_PROPERTY, LINK_MAPPING, POINT_MAPPING, GPX_MAPPING, _XmlMapper, XmlMapper;
var init_gpxjs = __esm({
"node_modules/@we-gold/gpxjs/dist/gpxjs.js"() {
__defProp = Object.defineProperty;
__defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
__name = (target, value) => __defProp(target, "name", { value, configurable: true });
__publicField = (obj, key, value) => (__defNormalProp(obj, typeof key != "symbol" ? key + "" : key, value), value);
DEFAULT_OPTIONS = {
// delete null fields in output
removeEmptyFields: true,
// average speed threshold (in m/ms) used to determine the moving duration
avgSpeedThreshold: 215e-6
};
calculateDistance = /* @__PURE__ */ __name((points) => {
const cumulativeDistance = [0];
for (let i = 0; i < points.length - 1; i++) {
const currentTotalDistance = cumulativeDistance[i] + haversineDistance(points[i], points[i + 1]);
cumulativeDistance.push(currentTotalDistance);
}
return {
cumulative: cumulativeDistance,
total: cumulativeDistance[cumulativeDistance.length - 1]
};
}, "calculateDistance");
haversineDistance = /* @__PURE__ */ __name((point1, point2) => {
const toRadians = /* @__PURE__ */ __name((degrees) => degrees * Math.PI / 180, "toRadians"), lat1Radians = toRadians(point1.latitude), lat2Radians = toRadians(point2.latitude), sinDeltaLatitude = Math.sin(
toRadians(point2.latitude - point1.latitude) / 2
), sinDeltaLongitude = Math.sin(
toRadians(point2.longitude - point1.longitude) / 2
), a = sinDeltaLatitude ** 2 + Math.cos(lat1Radians) * Math.cos(lat2Radians) * sinDeltaLongitude ** 2;
return 6371e3 * (2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
}, "haversineDistance");
calculateDuration = /* @__PURE__ */ __name((points, distance, calculOptions = DEFAULT_OPTIONS) => {
var _a;
const { avgSpeedThreshold } = calculOptions, allTimedPoints = [], cumulative = [0];
let lastTime = 0;
for (let i = 0; i < points.length - 1; i++) {
const time = points[i].time, dist = distance.cumulative[i], previousPoint = cumulative[i];
if (time !== null) {
const movingTime = time.getTime() - lastTime;
if (movingTime > 0) {
let sumDistances = 0, sumTime = 0;
for (let j = i; j >= 0; j--) {
const prevTime = (_a = points[j].time) == null ? void 0 : _a.getTime();
if (prevTime !== void 0) {
const timeDiff = time.getTime() - prevTime;
if (timeDiff > 1e4)
break;
sumDistances += distance.cumulative[j + 1] - distance.cumulative[j], sumTime += timeDiff;
}
}
const nextCumul = (sumTime > 0 ? sumDistances / sumTime : 0) > avgSpeedThreshold ? previousPoint + movingTime : previousPoint;
cumulative.push(nextCumul);
} else
cumulative.push(previousPoint);
lastTime = time.getTime(), allTimedPoints.push({ time, distance: dist });
} else
cumulative.push(previousPoint);
}
const totalDuration = allTimedPoints.length === 0 ? 0 : allTimedPoints[allTimedPoints.length - 1].time.getTime() - allTimedPoints[0].time.getTime();
return {
startTime: allTimedPoints.length ? allTimedPoints[0].time : null,
endTime: allTimedPoints.length ? allTimedPoints[allTimedPoints.length - 1].time : null,
cumulative,
movingDuration: cumulative[cumulative.length - 1] / 1e3,
// Convert to seconds
totalDuration: totalDuration / 1e3
// Convert to seconds
};
}, "calculateDuration");
calculateElevation = /* @__PURE__ */ __name((points) => {
var _a, _b;
let dp = 0, dn = 0;
const elevation = [];
let sum = 0;
for (let i = 0; i < points.length - 1; i++) {
const nextElevation = (_a = points[i + 1]) == null ? void 0 : _a.elevation, currentElevation = (_b = points[i]) == null ? void 0 : _b.elevation;
if (nextElevation !== null && currentElevation !== null) {
const diff = nextElevation - currentElevation;
diff < 0 ? dn += diff : diff > 0 && (dp += diff);
}
}
for (const point of points)
point.elevation !== null && (elevation.push(point.elevation), sum += point.elevation);
let max = elevation[0] ?? null, min = elevation[0] ?? null;
for (let i = 1; i < elevation.length; i++)
elevation[i] > max && (max = elevation[i]), elevation[i] < min && (min = elevation[i]);
return {
maximum: max,
minimum: min,
positive: Math.abs(dp) || null,
negative: Math.abs(dn) || null,
average: elevation.length ? sum / elevation.length : null
};
}, "calculateElevation");
calculateSlopes = /* @__PURE__ */ __name((points, cumulativeDistance) => {
var _a, _b;
const slopes = [];
for (let i = 0; i < points.length - 1; i++) {
const nextElevation = (_a = points[i + 1]) == null ? void 0 : _a.elevation, currentElevation = (_b = points[i]) == null ? void 0 : _b.elevation;
if (nextElevation !== null && currentElevation !== null) {
const elevationDifference = nextElevation - currentElevation, displacement = cumulativeDistance[i + 1] - cumulativeDistance[i], slope = elevationDifference * 100 / displacement;
slopes.push(slope);
}
}
return slopes;
}, "calculateSlopes");
parseGPX = /* @__PURE__ */ __name((gpxSource, options = DEFAULT_OPTIONS) => {
const parseMethod = /* @__PURE__ */ __name((gpxSource2) => typeof document > "u" ? null : typeof window > "u" ? (console.error(
"window is undefined, try to use the parseGPXWithCustomParser method"
), null) : new window.DOMParser().parseFromString(gpxSource2, "text/xml"), "parseMethod"), allOptions = { ...DEFAULT_OPTIONS, ...options };
return parseGPXWithCustomParser(gpxSource, parseMethod, allOptions);
}, "parseGPX");
parseGPXWithCustomParser = /* @__PURE__ */ __name((gpxSource, parseGPXToXML, options = DEFAULT_OPTIONS) => {
const parsedSource = parseGPXToXML(gpxSource);
if (parsedSource === null)
return [null, new Error("Provided parsing method failed.")];
const output = {
xml: parsedSource,
metadata: {
name: "",
description: "",
time: "",
author: null,
link: null
},
waypoints: [],
tracks: [],
routes: []
}, metadata = output.xml.querySelector("metadata");
if (metadata !== null) {
output.metadata.name = getElementValue(metadata, "name"), output.metadata.description = getElementValue(metadata, "desc"), output.metadata.time = getElementValue(metadata, "time");
const authorElement = metadata.querySelector("author");
if (authorElement !== null) {
const emailElement = authorElement.querySelector("email"), linkElement2 = authorElement.querySelector("link");
output.metadata.author = {
name: getElementValue(authorElement, "name"),
email: emailElement !== null ? {
id: emailElement.getAttribute("id") ?? "",
domain: emailElement.getAttribute("domain") ?? ""
} : null,
link: linkElement2 !== null ? {
href: linkElement2.getAttribute("href") ?? "",
text: getElementValue(linkElement2, "text"),
type: getElementValue(linkElement2, "type")
} : null
};
}
const linkElement = querySelectDirectDescendant(metadata, "link");
linkElement !== null && (output.metadata.link = {
href: linkElement.getAttribute("href") ?? "",
text: getElementValue(linkElement, "text"),
type: getElementValue(linkElement, "type")
});
}
const waypoints2 = Array.from(output.xml.querySelectorAll("wpt"));
for (const waypoint of waypoints2) {
const point = {
name: getElementValue(waypoint, "name"),
symbol: getElementValue(waypoint, "sym"),
latitude: parseFloat(waypoint.getAttribute("lat") ?? ""),
longitude: parseFloat(waypoint.getAttribute("lon") ?? ""),
elevation: null,
comment: getElementValue(waypoint, "cmt"),
description: getElementValue(waypoint, "desc"),
time: null
}, rawElevation = parseFloat(getElementValue(waypoint, "ele") ?? "");
point.elevation = isNaN(rawElevation) ? null : rawElevation;
const rawTime = getElementValue(waypoint, "time");
point.time = rawTime == null ? null : new Date(rawTime), output.waypoints.push(point);
}
const routes = Array.from(output.xml.querySelectorAll("rte"));
for (const routeElement of routes) {
const route = {
name: getElementValue(routeElement, "name"),
comment: getElementValue(routeElement, "cmt"),
description: getElementValue(routeElement, "desc"),
src: getElementValue(routeElement, "src"),
number: getElementValue(routeElement, "number"),
type: null,
link: null,
points: [],
distance: {
cumulative: [],
total: 0
},
duration: {
cumulative: [],
movingDuration: 0,
totalDuration: 0,
endTime: null,
startTime: null
},
elevation: {
maximum: null,
minimum: null,
average: null,
positive: null,
negative: null
},
slopes: []
}, type = querySelectDirectDescendant(routeElement, "type");
route.type = (type == null ? void 0 : type.innerHTML) ?? (type == null ? void 0 : type.textContent) ?? null;
const linkElement = routeElement.querySelector("link");
linkElement !== null && (route.link = {
href: linkElement.getAttribute("href") ?? "",
text: getElementValue(linkElement, "text"),
type: getElementValue(linkElement, "type")
});
const routePoints = Array.from(routeElement.querySelectorAll("rtept"));
for (const routePoint of routePoints) {
const point = {
latitude: parseFloat(routePoint.getAttribute("lat") ?? ""),
longitude: parseFloat(routePoint.getAttribute("lon") ?? ""),
elevation: null,
time: null,
extensions: null
}, rawElevation = parseFloat(
getElementValue(routePoint, "ele") ?? ""
);
point.elevation = isNaN(rawElevation) ? null : rawElevation;
const rawTime = getElementValue(routePoint, "time");
point.time = rawTime == null ? null : new Date(rawTime), route.points.push(point);
}
route.distance = calculateDistance(route.points), route.duration = calculateDuration(
route.points,
route.distance,
options
), route.elevation = calculateElevation(route.points), route.slopes = calculateSlopes(route.points, route.distance.cumulative), output.routes.push(route);
}
const tracks2 = Array.from(output.xml.querySelectorAll("trk"));
for (const trackElement of tracks2) {
const track = {
name: getElementValue(trackElement, "name"),
comment: getElementValue(trackElement, "cmt"),
description: getElementValue(trackElement, "desc"),
src: getElementValue(trackElement, "src"),
number: getElementValue(trackElement, "number"),
type: null,
link: null,
points: [],
distance: {
cumulative: [],
total: 0
},
duration: {
cumulative: [],
movingDuration: 0,
totalDuration: 0,
startTime: null,
endTime: null
},
elevation: {
maximum: null,
minimum: null,
average: null,
positive: null,
negative: null
},
slopes: []
}, type = querySelectDirectDescendant(trackElement, "type");
track.type = (type == null ? void 0 : type.innerHTML) ?? (type == null ? void 0 : type.textContent) ?? null;
const linkElement = trackElement.querySelector("link");
linkElement !== null && (track.link = {
href: linkElement.getAttribute("href") ?? "",
text: getElementValue(linkElement, "text"),
type: getElementValue(linkElement, "type")
});
const trackPoints = Array.from(trackElement.querySelectorAll("trkpt"));
for (const trackPoint of trackPoints) {
const point = {
latitude: parseFloat(trackPoint.getAttribute("lat") ?? ""),
longitude: parseFloat(trackPoint.getAttribute("lon") ?? ""),
elevation: null,
time: null,
extensions: null
}, extensionsElement = trackPoint.querySelector("extensions");
if (extensionsElement !== null) {
let extensions = {};
extensions = parseExtensions(
extensions,
extensionsElement.childNodes
), point.extensions = extensions;
}
const rawElevation = parseFloat(
getElementValue(trackPoint, "ele") ?? ""
);
point.elevation = isNaN(rawElevation) ? null : rawElevation;
const rawTime = getElementValue(trackPoint, "time");
point.time = rawTime == null ? null : new Date(rawTime), track.points.push(point);
}
track.distance = calculateDistance(track.points), track.duration = calculateDuration(
track.points,
track.distance,
options
), track.elevation = calculateElevation(track.points), track.slopes = calculateSlopes(track.points, track.distance.cumulative), output.tracks.push(track);
}
return options.removeEmptyFields && (deleteNullFields(output.metadata), deleteNullFields(output.waypoints), deleteNullFields(output.tracks), deleteNullFields(output.routes)), [new ParsedGPX(output, options), null];
}, "parseGPXWithCustomParser");
parseExtensions = /* @__PURE__ */ __name((extensions, extensionChildrenCollection) => (Array.from(extensionChildrenCollection).filter((child) => child.nodeType === 1).forEach((child) => {
var _a;
const tagName = child.nodeName;
if (((_a = child.childNodes) == null ? void 0 : _a.length) === 1 && child.childNodes[0].nodeType === 3 && child.childNodes[0].textContent) {
const textContent = child.childNodes[0].textContent.trim(), value = isNaN(+textContent) ? textContent : parseFloat(textContent);
extensions[tagName] = value;
} else
extensions[tagName] = {}, extensions[tagName] = parseExtensions(
extensions[tagName],
child.childNodes
);
}), extensions), "parseExtensions");
getElementValue = /* @__PURE__ */ __name((parent, tag) => {
var _a;
const element = parent.querySelector(tag);
return element !== null ? ((_a = element.firstChild) == null ? void 0 : _a.textContent) ?? element.innerHTML ?? null : null;
}, "getElementValue");
querySelectDirectDescendant = /* @__PURE__ */ __name((parent, tag) => {
try {
return parent.querySelector(`:scope > ${tag}`);
} catch {
return parent.childNodes ? Array.from(parent.childNodes).find(
(element) => element.tagName == tag
) ?? null : null;
}
}, "querySelectDirectDescendant");
deleteNullFields = /* @__PURE__ */ __name((object) => {
if (!(typeof object != "object" || object === null || object === void 0)) {
if (Array.isArray(object)) {
object.forEach(deleteNullFields);
return;
}
for (const [key, value] of Object.entries(object))
value == null || value == null ? delete object[key] : deleteNullFields(value);
}
}, "deleteNullFields");
_ParsedGPX = class _ParsedGPX2 {
constructor({ xml, metadata, waypoints: waypoints2, tracks: tracks2, routes }, options) {
__publicField(this, "xml");
__publicField(this, "metadata");
__publicField(this, "waypoints");
__publicField(this, "tracks");
__publicField(this, "routes");
__publicField(this, "options");
this.xml = xml, this.metadata = metadata, this.waypoints = waypoints2, this.tracks = tracks2, this.routes = routes, this.options = options;
}
/**
* Outputs the GPX data as GeoJSON, returning a JavaScript Object.
*
* @returns The GPX data converted to the GeoJSON format
*/
toGeoJSON() {
const GeoJSON2 = {
type: "FeatureCollection",
features: [],
properties: this.metadata
}, addFeature = /* @__PURE__ */ __name((track) => {
const {
name,
comment,
description,
src,
number,
link,
type,
points
} = track, feature = {
type: "Feature",
geometry: { type: "LineString", coordinates: [] },
properties: {
name,
comment,
description,
src,
number,
link,
type
}
};
for (const point of points) {
const { longitude, latitude, elevation } = point;
feature.geometry.coordinates.push([
longitude,
latitude,
elevation
]);
}
GeoJSON2.features.push(feature);
}, "addFeature");
for (const track of [...this.tracks, ...this.routes])
addFeature(track);
for (const waypoint of this.waypoints) {
const {
name,
symbol,
comment,
description,
longitude,
latitude,
elevation
} = waypoint, feature = {
type: "Feature",
geometry: {
type: "Point",
coordinates: [longitude, latitude, elevation]
},
properties: { name, symbol, comment, description }
};
GeoJSON2.features.push(feature);
}
return this.options.removeEmptyFields && deleteNullFields(GeoJSON2), GeoJSON2;
}
applyToTrack(trackIndex, func, ...args) {
if (trackIndex < 0 || trackIndex >= this.tracks.length) {
console.error("The track index is out of bounds.");
return;
}
try {
return func(this.tracks[trackIndex].points, ...args);
} catch (error) {
throw new Error(
`An error occurred in the applyToTrack function.
${error}
Check that the track index is valid, and that the function has the correct arguments.`
);
}
}
applyToRoute(routeIndex, func, ...args) {
if (routeIndex < 0 || routeIndex >= this.routes.length) {
console.error("The route index is out of bounds.");
return;
}
try {
return func(this.routes[routeIndex].points, ...args);
} catch (error) {
throw new Error(
`An error occurred in the applyToRoute function.
${error}
Check that the route index is valid, and that the function has the correct arguments.`
);
}
}
};
__name(_ParsedGPX, "ParsedGPX");
ParsedGPX = _ParsedGPX;
__name(stringifyGPX, "stringifyGPX");
GPX_NS = "http://www.topografix.com/GPX/1/1";
EXPR_PROPERTY = "$expr";
FOR_PROPERTY = "$for";
FUNC_PROPERTY = "$func";
LINK_MAPPING = {
"@href": "=",
text: "=",
type: "="
};
__name(ExtensionsMapping, "ExtensionsMapping");
POINT_MAPPING = {
"@lat": "latitude",
"@lon": "longitude",
ele: "elevation",
time: "=",
extensions: {
$func: ExtensionsMapping
}
};
GPX_MAPPING = {
metadata: {
name: "=",
desc: "description",
author: {
name: "=",
email: {
"@id": "=",
"@domain": "="
},
link: LINK_MAPPING
},
link: LINK_MAPPING,
time: "="
},
wpt: {
$expr: "waypoints",
$for: {
"@lat": "latitude",
"@lon": "longitude",
name: "=",
desc: "description",
ele: "elevation",
time: "=",
cmt: "comment"
}
},
trk: {
$expr: "tracks",
$for: {
name: "=",
cmt: "comment",
desc: "description",
src: "=",
number: "=",
link: LINK_MAPPING,
type: "=",
trkseg: {
$expr: ".",
trkpt: {
$expr: "points",
$for: POINT_MAPPING
}
}
}
},
rte: {
$expr: "routes",
$for: {
name: "=",
cmt: "comment",
desc: "description",
src: "=",
number: "=",
link: LINK_MAPPING,
type: "=",
rtept: {
$expr: "points",
$for: POINT_MAPPING
}
}
}
};
_XmlMapper = class _XmlMapper2 {
constructor(doc) {
__publicField(this, "doc");
this.doc = doc;
}
/**
* Generate XML attributes and elements using the given mapping.
*/
mapObject(objectMapping, srcObj, dstElem) {
for (const field in objectMapping)
field !== EXPR_PROPERTY && this.mapField(field, objectMapping[field], srcObj, dstElem);
}
/**
* Generate XML elements and attributes for the specified field.
*/
mapField(fieldExpr, mapping, srcObj, dstElem) {
const isAttribute = fieldExpr.startsWith("@"), fieldName = isAttribute ? fieldExpr.substring(1) : fieldExpr;
if (typeof mapping == "object") {
const fieldMapping = mapping, fieldValue = this.evalExpr(srcObj, fieldMapping[EXPR_PROPERTY] ?? "=", fieldName);
if (fieldValue == null)
return;
const forMapping = fieldMapping[FOR_PROPERTY];
if (forMapping)
for (const value of fieldValue) {
const elem = this.doc.createElementNS(GPX_NS, fieldName);
dstElem.append(elem), this.mapObject(forMapping, value, elem);
}
else {
const elem = this.doc.createElementNS(GPX_NS, fieldName);
dstElem.append(elem);
const funcMapping = fieldMapping[FUNC_PROPERTY];
funcMapping ? funcMapping(this.doc, fieldValue, elem) : this.mapObject(mapping, fieldValue, elem);
}
} else if (typeof mapping == "string") {
const value = this.evalExpr(srcObj, mapping, fieldName);
if (value == null)
return;
if (isAttribute)
dstElem.setAttribute(fieldName, value);
else {
const valueElem = this.doc.createElementNS(GPX_NS, fieldName);
dstElem.append(valueElem);
const node = this.doc.createTextNode(value);
valueElem.append(node);
}
} else
throw new Error(`Unsupported field mapping: ${mapping}`);
}
/**
* Evalutes a field expression for the specified object. If the expression
* equals `=`, then the specified `fieldName` will be used.
*/
evalExpr(srcObj, expr, fieldName) {
let property = expr;
if (expr === ".")
return srcObj;
expr === "=" && (property = fieldName);
const value = srcObj[property];
return value != null && typeof value == "object" && fieldName === "time" ? value.toISOString() : value;
}
};
__name(_XmlMapper, "XmlMapper");
XmlMapper = _XmlMapper;
}
});
// io.js
function parseGPXTracks(text, baseName) {
const [parsed, err] = parseGPX(text, { removeEmptyFields: false });
if (err || !parsed) {
console.warn("GPX parse error:", err);
return { tracks: [], waypoints: [] };
}
const result = { tracks: [], waypoints: [], _gpxParsed: parsed };
const xml = parsed.xml;
const trkEls = Array.from(xml.querySelectorAll("trk"));
for (let ti = 0; ti < trkEls.length && ti < parsed.tracks.length; ti++) {
const trkEl = trkEls[ti];
const nameEl = trkEl.querySelector(":scope > name");
const trkName = nameEl ? nameEl.textContent.trim() : baseName;
const segs = trkEl.querySelectorAll("trkseg");
const gpxTrack = parsed.tracks[ti];
if (segs.length <= 1) {
const coords = gpxTrack.points.map((p) => [p.longitude, p.latitude, p.elevation]);
if (coords.length) {
result.tracks.push({ name: trkName, coords, _gpxParsed: parsed, _gpxTrackIdx: ti });
}
} else {
const groupId = "grp-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6);
let offset = 0;
for (let si = 0; si < segs.length; si++) {
const segPtCount = segs[si].querySelectorAll("trkpt").length;
const segPoints = gpxTrack.points.slice(offset, offset + segPtCount);
const coords = segPoints.map((p) => [p.longitude, p.latitude, p.elevation]);
if (coords.length) {
result.tracks.push({
name: `${trkName} seg${si + 1}`,
coords,
_gpxParsed: parsed,
_gpxTrackIdx: ti,
_segmentIndex: si,
groupId,
groupName: trkName,
segmentLabel: `seg ${si + 1}`
});
}
offset += segPtCount;
}
}
}
for (let i = 0; i < parsed.routes.length; i++) {
const gr = parsed.routes[i];
const name = gr.name || baseName;
const coords = gr.points.map((p) => [p.longitude, p.latitude, p.elevation]);
if (coords.length) {
result.tracks.push({ name, coords, _gpxParsed: parsed, _gpxRouteIdx: i });
}
}
for (const wp of parsed.waypoints) {
result.waypoints.push({
name: wp.name,
coords: [wp.longitude, wp.latitude, wp.elevation],
sym: wp.symbol,
desc: wp.description,
comment: wp.comment
});
}
return result;
}
function parseGeoJSON(text) {
const gj = JSON.parse(text);
const results = [];
function extractCoords(geom) {
if (geom.type === "LineString") {
results.push(geom.coordinates.map((c) => [c[0], c[1], c[2] != null ? c[2] : null]));
} else if (geom.type === "MultiLineString") {
for (const line of geom.coordinates) {
results.push(line.map((c) => [c[0], c[1], c[2] != null ? c[2] : null]));
}
}
}
if (gj.type === "FeatureCollection") {
for (const f of gj.features) extractCoords(f.geometry);
} else if (gj.type === "Feature") {
extractCoords(gj.geometry);
} else {
extractCoords(gj);
}
return results;
}
function importFileContent(filename, text) {
const baseName = filename.replace(/\.[^.]+$/, "");
if (filename.endsWith(".gpx")) {
const result = parseGPXTracks(text, baseName);
if (!result.tracks.length && !result.waypoints.length) {
console.warn("No tracks or waypoints found in", filename);
return;
}
for (const trk of result.tracks) {
const t = tracksFns2.createTrack(trk.name, trk.coords, {
groupId: trk.groupId,
groupName: trk.groupName,
segmentLabel: trk.segmentLabel
});
tracksFns2.fitToTrack(t);
}
if (result.waypoints.length && tracksFns2.addWaypoints) {
tracksFns2.addWaypoints(result.waypoints);
}
} else {
const coordsList = parseGeoJSON(text);
if (!coordsList.length) {
console.warn("No tracks found in", filename);
return;
}
for (let i = 0; i < coordsList.length; i++) {
const name = coordsList.length > 1 ? `${baseName} (${i + 1})` : baseName;
const t = tracksFns2.createTrack(name, coordsList[i]);
tracksFns2.fitToTrack(t);
}
}
}
function escapeXml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function buildTrackGPXString(name, coords) {
const pts = coords.map((c) => {
const ele = c[2] != null ? `<ele>${c[2]}</ele>` : "";
return ` <trkpt lat="${c[1]}" lon="${c[0]}">${ele}</trkpt>`;
}).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="slope-editor">
<trk>
<name>${escapeXml(name)}</name>
<trkseg>
${pts}
</trkseg>
</trk>
</gpx>`;
}
function exportActiveGPX() {
const t = tracksFns2.getActiveTrack();
if (!t || !t.coords.length) return;
downloadFile(t.name + ".gpx", buildTrackGPXString(t.name, t.coords), "application/gpx+xml");
}
function exportActiveGeoJSON() {
const t = tracksFns2.getActiveTrack();
if (!t || !t.coords.length) return;
const gj = {
type: "Feature",
properties: { name: t.name },
geometry: {
type: "LineString",
coordinates: t.coords.map((c) => c[2] != null ? [c[0], c[1], c[2]] : [c[0], c[1]])
}
};
downloadFile(t.name + ".geojson", JSON.stringify(gj, null, 2), "application/geo+json");
}
function buildWaypointsGPXFragment(waypoints2) {
if (!waypoints2 || !waypoints2.length) return "";
return waypoints2.map((wp) => {
const ele = wp.coords[2] != null ? `
<ele>${wp.coords[2]}</ele>` : "";
const name = wp.name ? `
<name>${escapeXml(wp.name)}</name>` : "";
const sym = wp.sym ? `
<sym>${escapeXml(wp.sym)}</sym>` : "";
const desc = wp.desc ? `
<desc>${escapeXml(wp.desc)}</desc>` : "";
const cmt = wp.comment ? `
<cmt>${escapeXml(wp.comment)}</cmt>` : "";
return ` <wpt lat="${wp.coords[1]}" lon="${wp.coords[0]}">${ele}${name}${sym}${desc}${cmt}
</wpt>`;
}).join("\n");
}
function exportAllGPX() {
const tracks2 = tracksFns2.getTracks();
const wpts = tracksFns2.getWaypoints ? tracksFns2.getWaypoints() : [];
if (!tracks2.length && !wpts.length) return;
const wptXml = buildWaypointsGPXFragment(wpts);
const trkFragments = [];
const grouped = /* @__PURE__ */ new Map();
const ungrouped = [];
for (const t of tracks2) {
if (t.groupId) {
if (!grouped.has(t.groupId)) grouped.set(t.groupId, []);
grouped.get(t.groupId).push(t);
} else {
ungrouped.push(t);
}
}
for (const [, group] of grouped) {
const name = group[0].groupName || group[0].name;
const segs = group.map((t) => {
const pts = t.coords.map((c) => {
const ele = c[2] != null ? `<ele>${c[2]}</ele>` : "";
return ` <trkpt lat="${c[1]}" lon="${c[0]}">${ele}</trkpt>`;
}).join("\n");
return ` <trkseg>
${pts}
</trkseg>`;
}).join("\n");
trkFragments.push(` <trk>
<name>${escapeXml(name)}</name>
${segs}
</trk>`);
}
for (const t of ungrouped) {
const pts = t.coords.map((c) => {
const ele = c[2] != null ? `<ele>${c[2]}</ele>` : "";
return ` <trkpt lat="${c[1]}" lon="${c[0]}">${ele}</trkpt>`;
}).join("\n");
trkFragments.push(` <trk>
<name>${escapeXml(t.name)}</name>
<trkseg>
${pts}
</trkseg>
</trk>`);
}
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="slope-editor">
${wptXml}
${trkFragments.join("\n")}
</gpx>`;
downloadFile("all-tracks.gpx", gpx, "application/gpx+xml");
}
function initIO(deps) {
tracksFns2 = deps;
const dropOverlay = document.getElementById("drop-overlay");
let dragCounter = 0;
document.addEventListener("dragenter", (e) => {
e.preventDefault();
dragCounter++;
dropOverlay.classList.add("visible");
});
document.addEventListener("dragleave", (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
dropOverlay.classList.remove("visible");
}
});
document.addEventListener("dragover", (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
});
document.addEventListener("drop", async (e) => {
e.preventDefault();
dragCounter = 0;
dropOverlay.classList.remove("visible");
const items = e.dataTransfer.items;
if (items && items.length) {
const entries = [];
for (const item of items) {
const entry = item.webkitGetAsEntry?.();
if (entry) entries.push(entry);
}
if (entries.some((en) => en.isDirectory)) {
for (const entry of entries) {
if (entry.isDirectory) await readDirectoryEntries(entry);
else if (entry.isFile) await readFileEntry(entry);
}
return;
}
}
for (const file of e.dataTransfer.files) {
const reader = new FileReader();
reader.onload = () => importFileContent(file.name, reader.result);
reader.readAsText(file);
}
});
document.getElementById("export-gpx-btn").addEventListener("click", exportActiveGPX);
document.getElementById("export-geojson-btn").addEventListener("click", exportActiveGeoJSON);
document.getElementById("export-all-gpx-btn").addEventListener("click", exportAllGPX);
const openFolderBtn = document.getElementById("open-folder-btn");
if (openFolderBtn) {
openFolderBtn.addEventListener("click", openFolder);
}
const saveFolderBtn = document.getElementById("save-folder-btn");
if (saveFolderBtn) {
if ("showDirectoryPicker" in window) {
saveFolderBtn.addEventListener("click", saveToFolder);
} else {
saveFolderBtn.style.display = "none";
}
}
}
async function openDirectoryPicker() {
const dirHandle = await window.showDirectoryPicker({ mode: "read" });
for await (const [name, handle] of dirHandle.entries()) {
if (handle.kind === "file" && FILE_PATTERN.test(name)) {
const file = await handle.getFile();
const text = await file.text();
importFileContent(name, text);
}
}
}
function openDirectoryInput() {
const input = document.createElement("input");
input.type = "file";
input.webkitdirectory = true;
input.multiple = true;
input.accept = ".gpx,.geojson,.json";
input.addEventListener("change", () => {
for (const file of input.files) {
if (FILE_PATTERN.test(file.name)) {
file.text().then((text) => importFileContent(file.name, text));
}
}
});
input.click();
}
async function openFolder() {
if ("showDirectoryPicker" in window) {
try {
await openDirectoryPicker();
return;
} catch (e) {
if (e.name === "AbortError") return;
console.warn("showDirectoryPicker failed, falling back:", e);
}
}
openDirectoryInput();
}
async function saveToFolder() {
const tracks2 = tracksFns2.getTracks();
if (!tracks2.length) return;
const dirHandle = await window.showDirectoryPicker({ mode: "readwrite" });
for (const t of tracks2) {
const safeName = t.name.replace(/[^a-z0-9._-]/gi, "_");
const fileHandle = await dirHandle.getFileHandle(safeName + ".gpx", { create: true });
const writable = await fileHandle.createWritable();
await writable.write(buildTrackGPXString(t.name, t.coords));
await writable.close();
}
}
async function readDirectoryEntries(dirEntry) {
const reader = dirEntry.createReader();
const entries = await new Promise((resolve) => {
reader.readEntries(resolve);
});
for (const entry of entries) {
if (entry.isFile && FILE_PATTERN.test(entry.name)) {
await readFileEntry(entry);
} else if (entry.isDirectory) {
await readDirectoryEntries(entry);
}
}
}
function readFileEntry(entry) {
return new Promise((resolve) => {
entry.file((file) => {
file.text().then((text) => {
importFileContent(file.name, text);
resolve();
});
});
});
}
var tracksFns2, FILE_PATTERN;
var init_io = __esm({
"io.js"() {
init_gpxjs();
init_utils();
tracksFns2 = {};
FILE_PATTERN = /\.(gpx|geojson|json)$/i;
}
});
// persist.js
function serializeTracks(tracks2) {
return tracks2.map((t) => ({
name: t.name,
color: t.color,
coords: t.coords,
groupId: t.groupId || void 0,
groupName: t.groupName || void 0,
segmentLabel: t.segmentLabel || void 0
}));
}
function saveTracks(tracks2) {
try {
localStorage.setItem(TRACKS_KEY, JSON.stringify(serializeTracks(tracks2)));
} catch {
}
}
function loadTracks() {
try {
const raw = localStorage.getItem(TRACKS_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveSettings(state4) {
try {
const obj = {};
for (const k of SETTING_KEYS) {
if (state4[k] !== void 0) obj[k] = state4[k];
}
localStorage.setItem(SETTINGS_KEY, JSON.stringify(obj));
} catch {
}
}
function loadSettings() {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function clearAll() {
try {
localStorage.removeItem(TRACKS_KEY);
localStorage.removeItem(SETTINGS_KEY);
} catch {
}
}
var TRACKS_KEY, SETTINGS_KEY, SETTING_KEYS;
var init_persist = __esm({
"persist.js"() {
TRACKS_KEY = "slope:tracks";
SETTINGS_KEY = "slope:settings";
SETTING_KEYS = [
"basemap",
"mode",
"slopeOpacity",
"basemapOpacity",
"hillshadeOpacity",
"hillshadeMethod",
"terrain3d",
"terrainExaggeration",
"multiplyBlend",
"showContours",
"showOpenSkiMap",
"cursorInfoMode"
];
}
});
// tracks.js
function scheduleSave() {
clearTimeout(_saveTimer);
_saveTimer = setTimeout(() => saveTracks(tracks), 300);
}
function nextColor() {
const c = TRACK_COLORS[trackColorIdx % TRACK_COLORS.length];
trackColorIdx++;
return c;
}
function invalidateTrackStats(t) {
if (t) t._statsCache = null;
}
function invalidateAllTrackStats() {
for (const t of tracks) invalidateTrackStats(t);
}
function elevationAt2(lngLat) {
const r = queryLoadedElevationAtLngLat(map2, lngLat);
return r ? Math.round(r.elevation * 10) / 10 : null;
}
function representativeTrackSampleSpacingMeters(coords, totalDistMeters) {
if (!coords.length) return 4;
const meanLat = coords.reduce((sum, c) => sum + c[1], 0) / coords.length;
const nominal = 400750167e-1 / Math.pow(2, DEM_MAX_Z) / CORE_DIM * Math.max(0.25, Math.cos(meanLat * Math.PI / 180));
return Math.max(2, nominal, totalDistMeters / 2e3);
}
function interpolateTrackLngLat(coords, cumulativeMeters, targetMeters) {
if (targetMeters <= 0) return { lng: coords[0][0], lat: coords[0][1] };
const totalMeters = cumulativeMeters[cumulativeMeters.length - 1];
if (targetMeters >= totalMeters) {
const last = coords[coords.length - 1];
return { lng: last[0], lat: last[1] };
}
let segIndex = 1;
while (segIndex < cumulativeMeters.length && cumulativeMeters[segIndex] < targetMeters) segIndex++;
const startMeters = cumulativeMeters[segIndex - 1];
const endMeters = cumulativeMeters[segIndex];
const spanMeters = endMeters - startMeters;
const t = spanMeters > 0 ? (targetMeters - startMeters) / spanMeters : 0;
const a = coords[segIndex - 1];
const b = coords[segIndex];
return {
lng: a[0] + (b[0] - a[0]) * t,
lat: a[1] + (b[1] - a[1]) * t
};
}
function computeTerrainSlopeAlongTrack(coords, cumulativeMeters, totalDistMeters) {
if (coords.length < 2 || totalDistMeters <= 0) {
return { average: null, maximum: null, sampleCount: 0, resolvedCount: 0 };
}
const spacingMeters = representativeTrackSampleSpacingMeters(coords, totalDistMeters);
const sampleCount = Math.max(2, Math.ceil(totalDistMeters / spacingMeters) + 1);
let sum = 0;
let maximum = -Infinity;
let resolvedCount = 0;
for (let i = 0; i < sampleCount; i++) {
const distanceMeters = sampleCount === 1 ? 0 : i / (sampleCount - 1) * totalDistMeters;
const lngLat = interpolateTrackLngLat(coords, cumulativeMeters, distanceMeters);
const sample = queryLoadedElevationAtLngLat(map2, lngLat);
if (!sample || !Number.isFinite(sample.slopeDeg)) continue;
sum += sample.slopeDeg;
maximum = Math.max(maximum, sample.slopeDeg);
resolvedCount++;
}
return {
average: resolvedCount ? sum / resolvedCount : null,
maximum: resolvedCount ? maximum : null,
sampleCount,
resolvedCount
};
}
function enrichElevation(coords) {
for (const c of coords) {
if (c[2] != null) continue;
const e = elevationAt2({ lng: c[0], lat: c[1] });
if (e != null) c[2] = e;
}
}
function enrichAllTracks() {
let anyUpdated = false;
for (const t of tracks) {
for (const c of t.coords) {
if (c[2] != null) continue;
const e = elevationAt2({ lng: c[0], lat: c[1] });
if (e != null) {
c[2] = e;
anyUpdated = true;
}
}
}
if (anyUpdated) {
invalidateAllTrackStats();
refreshAllTrackSources();
renderTrackList();
updateProfileFn2();
}
}
function trackSourceId(t) {
return "track-" + t.id;
}
function trackLineLayerId(t) {
return "track-line-" + t.id;
}
function trackPtsLayerId(t) {
return "track-pts-" + t.id;
}
function trackGeoJSON(t) {
const features = [];
if (t.coords.length >= 2) {
features.push({
type: "Feature",
geometry: { type: "LineString", coordinates: t.coords.map((c) => c.slice()) },
properties: { id: t.id }
});
}
const last = t.coords.length - 1;
for (let i = 0; i < t.coords.length; i++) {
const role = i === 0 ? "start" : i === last ? "end" : "mid";
features.push({
type: "Feature",
geometry: { type: "Point", coordinates: t.coords[i].slice() },
properties: { id: t.id, idx: i, role }
});
}
return { type: "FeatureCollection", features };
}
function addTrackToMap(t) {
const isActive = ["==", ["get", "id"], ["global-state", "activeTrackId"]];
const isEditing = ["==", ["get", "id"], ["global-state", "editingTrackId"]];
const isStartOrEnd = ["any", ["==", ["get", "role"], "start"], ["==", ["get", "role"], "end"]];
const isSelected = ["all", isEditing, ["==", ["get", "idx"], ["global-state", "selectedVertexIdx"]]];
map2.addSource(trackSourceId(t), { type: "geojson", data: trackGeoJSON(t) });
map2.addLayer({
id: trackLineLayerId(t),
type: "line",
source: trackSourceId(t),
filter: ["==", "$type", "LineString"],
paint: {
"line-color": t.color,
"line-width": ["case", isActive, 4, 3],
"line-opacity": 0.9
}
});
map2.addLayer({
id: trackPtsLayerId(t),
type: "circle",
source: trackSourceId(t),
filter: ["==", "$type", "Point"],
paint: {
"circle-radius": [
"case",
isSelected,
7,
["all", isEditing, ["==", ["get", "role"], "mid"]],
4,
isEditing,
4,
["all", isActive, isStartOrEnd],
4,
isStartOrEnd,
3,
0
],
"circle-color": [
"case",
isSelected,
"#4a90d9",
[
"match",
["get", "role"],
"start",
"#22c55e",
"end",
"#ef4444",
t.color
]
],
"circle-stroke-color": ["case", isSelected, "#fff", "#fff"],
"circle-stroke-width": [
"case",
isSelected,
2.5,
isEditing,
1.5,
["all", isActive, isStartOrEnd],
1.5,
isStartOrEnd,
1,
0
]
}
});
}
function ensureProfileHoverLayer() {
if (!map2.getSource(PROFILE_HOVER_SOURCE_ID)) {
map2.addSource(PROFILE_HOVER_SOURCE_ID, {
type: "geojson",
data: { type: "FeatureCollection", features: [] }
});
}
if (!map2.getLayer(PROFILE_HOVER_LAYER_ID)) {
map2.addLayer({
id: PROFILE_HOVER_LAYER_ID,
type: "circle",
source: PROFILE_HOVER_SOURCE_ID,
paint: {
"circle-radius": 7,
"circle-color": ["coalesce", ["get", "color"], "#4a90d9"],
"circle-opacity": 0.95,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 2.5
}
});
}
if (!map2.getSource(HOVER_INSERT_SOURCE_ID2)) {
map2.addSource(HOVER_INSERT_SOURCE_ID2, {
type: "geojson",
data: { type: "FeatureCollection", features: [] }
});
}
if (!map2.getLayer(HOVER_INSERT_LAYER_ID)) {
map2.addLayer({
id: HOVER_INSERT_LAYER_ID,
type: "circle",
source: HOVER_INSERT_SOURCE_ID2,
paint: {
"circle-radius": 5,
"circle-color": "rgba(128,128,128,0.5)",
"circle-stroke-color": "#fff",
"circle-stroke-width": 1
}
});
}
const INSERT_PREVIEW_SOURCE_ID = "insert-preview-line";
const INSERT_PREVIEW_LAYER_ID = "insert-preview-line-layer";
if (!map2.getSource(INSERT_PREVIEW_SOURCE_ID)) {
map2.addSource(INSERT_PREVIEW_SOURCE_ID, {
type: "geojson",
data: { type: "FeatureCollection", features: [] }
});
}
if (!map2.getLayer(INSERT_PREVIEW_LAYER_ID)) {
map2.addLayer({
id: INSERT_PREVIEW_LAYER_ID,
type: "line",
source: INSERT_PREVIEW_SOURCE_ID,
paint: {
"line-color": "rgba(74,144,217,0.6)",
"line-width": 2,
"line-dasharray": [4, 4]
}
});
}
}
function waypointGeoJSON() {
return {
type: "FeatureCollection",
features: waypoints.map((wp) => ({
type: "Feature",
geometry: { type: "Point", coordinates: wp.coords.slice(0, 2) },
properties: { name: wp.name || "", sym: wp.sym || "" }
}))
};
}
function ensureWaypointLayer() {
if (!map2.getSource(WAYPOINT_SOURCE_ID)) {
map2.addSource(WAYPOINT_SOURCE_ID, {
type: "geojson",
data: waypointGeoJSON()
});
}
if (!map2.getLayer(WAYPOINT_CIRCLE_LAYER_ID)) {
map2.addLayer({
id: WAYPOINT_CIRCLE_LAYER_ID,
type: "circle",
source: WAYPOINT_SOURCE_ID,
paint: {
"circle-radius": 6,
"circle-color": "#f59e0b",
"circle-stroke-color": "#fff",
"circle-stroke-width": 2
}
});
}
if (!map2.getLayer(WAYPOINT_LABEL_LAYER_ID)) {
map2.addLayer({
id: WAYPOINT_LABEL_LAYER_ID,
type: "symbol",
source: WAYPOINT_SOURCE_ID,
layout: {
"text-field": ["get", "name"],
"text-size": 12,
"text-offset": [0, 1.5],
"text-anchor": "top",
"text-font": ["Noto Sans Bold"]
},
paint: {
"text-color": "#1e293b",
"text-halo-color": "#ffffff",
"text-halo-width": 1.5
}
});
}
}
function refreshWaypointSource() {
const src = map2.getSource(WAYPOINT_SOURCE_ID);
if (src) src.setData(waypointGeoJSON());
}
function addWaypoints(newWaypoints) {
for (const wp of newWaypoints) {
waypoints.push({
id: "wpt-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6),
name: wp.name,
coords: wp.coords,
sym: wp.sym,
desc: wp.desc,
comment: wp.comment
});
}
if (waypoints.length) refreshWaypointSource();
}
function removeTrackFromMap(t) {
if (map2.getLayer(trackPtsLayerId(t))) map2.removeLayer(trackPtsLayerId(t));
if (map2.getLayer(trackLineLayerId(t))) map2.removeLayer(trackLineLayerId(t));
if (map2.getSource(trackSourceId(t))) map2.removeSource(trackSourceId(t));
}
function refreshTrackSource(t) {
const src = map2.getSource(trackSourceId(t));
if (src) src.setData(trackGeoJSON(t));
}
function refreshAllTrackSources() {
for (const t of tracks) refreshTrackSource(t);
}
function updateVertexHighlight(editingId, selIdx) {
map2.setGlobalStateProperty("activeTrackId", activeTrackId);
map2.setGlobalStateProperty("editingTrackId", editingId != null ? editingId : null);
map2.setGlobalStateProperty("selectedVertexIdx", selIdx != null ? selIdx : -1);
}
function syncProfileToggleButton() {
const t = getActiveTrack();
const profilePanel2 = document.getElementById("profile-panel");
const canShow = Boolean(t && t.coords.length >= 2);
profileToggleBtn.disabled = !canShow;
const isVisible = canShow && profilePanel2.classList.contains("visible") && !profileClosed;
profileToggleBtn.classList.toggle("active", isVisible);
profileToggleBtn.textContent = isVisible ? "Profile" : "Show Profile";
}
function syncTrackPanelShell() {
const isVisible = trackPanel.classList.contains("visible");
const hasTracks = tracks.length > 0;
const toolRowParent = isVisible ? trackPanelHeader : trackPanelShell;
const toolRowNextSibling = isVisible ? null : trackPanel;
if (trackToolRow.parentElement !== toolRowParent) {
toolRowParent.insertBefore(trackToolRow, toolRowNextSibling);
}
trackPanelShell.classList.toggle("visible", isVisible);
trackPanelShell.classList.toggle("panel-surface", isVisible);
tracksBtn.disabled = !hasTracks;
tracksBtn.classList.toggle("active", isVisible && hasTracks);
tracksBtn.textContent = isVisible && hasTracks ? "\xD7" : "\u{1F4CD}";
tracksBtn.title = hasTracks ? isVisible ? "Close tracks" : "Track list" : "No tracks";
}
function setTrackPanelVisible(visible) {
trackPanel.classList.toggle("visible", visible && tracks.length > 0);
syncTrackPanelShell();
}
function trackStats(t) {
if (!t || t.coords.length < 2) return null;
if (t._statsCache) return t._statsCache;
const coords = t.coords;
let dist = 0, gain = 0, loss = 0;
let weightedSlopeSum = 0;
let weightedSlopeDist = 0;
const cumulativeMeters = [0];
for (let i = 1; i < coords.length; i++) {
const segKm = haversineKm(coords[i - 1], coords[i]);
const segMeters = segKm * 1e3;
dist += segKm;
cumulativeMeters.push(cumulativeMeters[i - 1] + segMeters);
if (coords[i][2] != null && coords[i - 1][2] != null) {
const dh = coords[i][2] - coords[i - 1][2];
if (dh > 0) gain += dh;
else loss -= dh;
if (segMeters > 0) {
weightedSlopeSum += Math.atan2(Math.abs(dh), segMeters) * 180 / Math.PI * segMeters;
weightedSlopeDist += segMeters;
}
}
}
const terrainSlopeAlongTrack = computeTerrainSlopeAlongTrack(coords, cumulativeMeters, dist * 1e3);
const segmentMaxSlope = weightedSlopeDist > 0 ? coords.reduce((maxSlope, _coord, index) => {
if (index === 0) return maxSlope;
const segMeters = cumulativeMeters[index] - cumulativeMeters[index - 1];
if (segMeters <= 0 || coords[index][2] == null || coords[index - 1][2] == null) return maxSlope;
const dh = coords[index][2] - coords[index - 1][2];
const slopeDeg = Math.atan2(Math.abs(dh), segMeters) * 180 / Math.PI;
return Math.max(maxSlope, slopeDeg);
}, -Infinity) : null;
const maxTerrainSlope = terrainSlopeAlongTrack.maximum != null ? terrainSlopeAlongTrack.maximum : segmentMaxSlope != null && Number.isFinite(segmentMaxSlope) ? segmentMaxSlope : null;
t._statsCache = {
dist,
gain,
loss,
avgSlope: weightedSlopeDist > 0 ? weightedSlopeSum / weightedSlopeDist : null,
terrainSlopeAlongTrack: terrainSlopeAlongTrack.average,
maxTerrainSlope,
terrainSlopeResolvedCount: terrainSlopeAlongTrack.resolvedCount,
terrainSlopeSampleCount: terrainSlopeAlongTrack.sampleCount
};
return t._statsCache;
}
function buildTrackItemHTML(t) {
const s = t.coords.length >= 2 ? trackStats(t) : null;
const statsStr = s ? `${s.dist.toFixed(1)} km \xB7 \u2191${Math.round(s.gain)} m \xB7 \u2193${Math.round(s.loss)} m \xB7 ${t.coords.length} pts` : `${t.coords.length} pts`;
const detailStatsStr = s && t.id === activeTrackId ? `Avg slope: ${s.avgSlope != null ? `${s.avgSlope.toFixed(1)}\xB0` : "n/a"} \xB7 Max slope: ${s.maxTerrainSlope != null ? `${s.maxTerrainSlope.toFixed(1)}\xB0` : "n/a"}` : "";
const editActive = isTrackEditing(t.id);
return `<span class="track-color" style="background:${t.color}"></span><span class="track-name">${t.segmentLabel || t.name}` + (statsStr ? `<br><span class="track-stats">${statsStr}</span>` : "") + (detailStatsStr ? `<br><span class="track-stats">${detailStatsStr}</span>` : "") + `</span><button class="track-edit${editActive ? " active" : ""}" data-id="${t.id}" title="Edit track">&#9998;</button><button class="track-del" data-id="${t.id}">&times;</button>`;
}
function renderTrackList() {
trackListEl.innerHTML = "";
const grouped = /* @__PURE__ */ new Map();
const ungrouped = [];
for (const t of tracks) {
if (t.groupId) {
if (!grouped.has(t.groupId)) grouped.set(t.groupId, []);
grouped.get(t.groupId).push(t);
} else {
ungrouped.push(t);
}
}
const rendered = /* @__PURE__ */ new Set();
for (const t of tracks) {
if (rendered.has(t.id)) continue;
if (t.groupId && grouped.has(t.groupId)) {
const group = grouped.get(t.groupId);
grouped.delete(t.groupId);
const collapsed = collapsedGroups.has(t.groupId);
const header = document.createElement("div");
header.className = "track-group-header";
const anyActive = group.some((g) => g.id === activeTrackId);
if (anyActive) header.classList.add("active");
let totalDist = 0, totalGain = 0, totalLoss = 0, totalPts = 0;
for (const g of group) {
const gs = g.coords.length >= 2 ? trackStats(g) : null;
if (gs) {
totalDist += gs.dist;
totalGain += gs.gain;
totalLoss += gs.loss;
}
totalPts += g.coords.length;
}
const aggStats = totalPts >= 2 ? `${totalDist.toFixed(1)} km \xB7 \u2191${Math.round(totalGain)} m \xB7 \u2193${Math.round(totalLoss)} m \xB7 ${totalPts} pts` : `${totalPts} pts`;
header.innerHTML = `<span class="track-group-toggle">${collapsed ? "\u25B8" : "\u25BE"}</span><span class="track-color" style="background:${group[0].color}"></span><span class="track-name">${t.groupName || "Group"}<br><span class="track-stats">${aggStats} \xB7 ${group.length} segs</span></span>`;
header.addEventListener("click", () => {
if (collapsed) collapsedGroups.delete(t.groupId);
else collapsedGroups.add(t.groupId);
renderTrackList();
});
trackListEl.appendChild(header);
if (!collapsed) {
for (const g of group) {
rendered.add(g.id);
const div = document.createElement("div");
div.className = "track-item track-item-nested" + (g.id === activeTrackId ? " active" : "");
div.innerHTML = buildTrackItemHTML(g);
div.addEventListener("click", (e) => {
if (e.target.classList.contains("track-del") || e.target.classList.contains("track-edit")) return;
setActiveTrack(g.id);
});
div.querySelector(".track-edit").addEventListener("click", () => {
if (isTrackEditing(g.id)) exitEditMode();
else {
setActiveTrack(g.id);
enterEditMode(g.id);
}
});
div.querySelector(".track-del").addEventListener("click", () => deleteTrack(g.id));
trackListEl.appendChild(div);
}
} else {
for (const g of group) rendered.add(g.id);
}
} else if (!t.groupId) {
rendered.add(t.id);
const div = document.createElement("div");
div.className = "track-item" + (t.id === activeTrackId ? " active" : "");
div.innerHTML = buildTrackItemHTML(t);
div.addEventListener("click", (e) => {
if (e.target.classList.contains("track-del") || e.target.classList.contains("track-edit")) return;
setActiveTrack(t.id);
});
div.querySelector(".track-edit").addEventListener("click", () => {
if (isTrackEditing(t.id)) exitEditMode();
else {
setActiveTrack(t.id);
enterEditMode(t.id);
}
});
div.querySelector(".track-del").addEventListener("click", () => deleteTrack(t.id));
trackListEl.appendChild(div);
}
}
if (!tracks.length) setTrackPanelVisible(false);
else syncTrackPanelShell();
syncProfileToggleButton();
const es = getEditState();
es.syncUndoBtn();
}
function setActiveTrack(id) {
if (id !== activeTrackId) profileClosed = false;
const es = getEditState();
if (es.editingTrackId && es.editingTrackId !== id) exitEditMode();
activeTrackId = id;
renderTrackList();
updateVertexHighlight(es.editingTrackId, es.selectedVertexIndex);
updateProfileFn2();
}
function deleteTrack(id) {
const t = tracks.find((tr) => tr.id === id);
if (!t) return;
if (!confirm(`Delete track "${t.name}"?`)) return;
const idx = tracks.indexOf(t);
const es = getEditState();
if (es.editingTrackId === id) exitEditMode();
removeTrackFromMap(t);
tracks.splice(idx, 1);
if (activeTrackId === id) activeTrackId = tracks.length ? tracks[tracks.length - 1].id : null;
renderTrackList();
updateVertexHighlight(es.editingTrackId, es.selectedVertexIndex);
scheduleSave();
}
function createTrack(name, coords, opts) {
const t = {
id: "trk-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6),
name,
color: nextColor(),
coords,
_statsCache: null,
groupId: opts?.groupId || null,
groupName: opts?.groupName || null,
segmentLabel: opts?.segmentLabel || null
};
enrichElevation(t.coords);
tracks.push(t);
if (mapReady) addTrackToMap(t);
setTrackPanelVisible(true);
setActiveTrack(t.id);
scheduleSave();
return t;
}
function getActiveTrack() {
return tracks.find((t) => t.id === activeTrackId) || null;
}
function fitToTrack(t) {
if (t.coords.length < 1) return;
const bounds = t.coords.reduce(
(b, c) => b.extend([c[0], c[1]]),
new maplibregl.LngLatBounds()
);
map2.fitBounds(bounds, { padding: 60, maxZoom: 15, duration: 1e3 });
}
function onTrackCoordsChanged(t) {
invalidateTrackStats(t);
refreshTrackSource(t);
renderTrackList();
updateProfileFn2();
scheduleSave();
}
function invalidateAndRefresh(t) {
invalidateTrackStats(t);
refreshTrackSource(t);
}
function removeIncompleteNewTrack(t) {
const idx = tracks.indexOf(t);
if (idx >= 0) {
removeTrackFromMap(t);
tracks.splice(idx, 1);
if (activeTrackId === t.id) activeTrackId = tracks.length ? tracks[tracks.length - 1].id : null;
scheduleSave();
}
}
function createNewTrack() {
return createTrack("Track " + (tracks.length + 1), []);
}
function getTracksState() {
const es = getEditState();
return {
tracks,
waypoints,
get activeTrackId() {
return activeTrackId;
},
get editingTrackId() {
return es.editingTrackId;
},
get selectedVertexIndex() {
return es.selectedVertexIndex;
},
get insertAfterIdx() {
return es.insertAfterIdx;
},
get mobileFriendlyMode() {
return es.mobileFriendlyMode;
},
get mapReady() {
return mapReady;
},
get profileClosed() {
return profileClosed;
},
set profileClosed(v) {
profileClosed = v;
},
getActiveTrack,
ensureProfileHoverLayer,
PROFILE_HOVER_SOURCE_ID,
syncProfileToggleButton,
syncBottomRightOffset() {
document.body.classList.toggle("profile-open", document.getElementById("profile-panel").classList.contains("visible"));
}
};
}
function initTracks(mapRef, stateRef, updateProfile2) {
map2 = mapRef;
state2 = stateRef;
updateProfileFn2 = updateProfile2;
tracksBtn = document.getElementById("tracks-btn");
trackPanelShell = document.getElementById("track-panel-shell");
trackToolRow = document.getElementById("track-tool-row");
trackPanel = document.getElementById("track-panel");
trackPanelHeader = trackPanel.querySelector(".track-panel-header");
trackListEl = document.getElementById("track-list");
profileToggleBtn = document.getElementById("profile-toggle-btn");
syncTrackPanelShell();
const saved = loadTracks();
for (const st of saved) {
if (!st.coords || !st.coords.length) continue;
const t = {
id: "trk-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6),
name: st.name || "Track",
color: st.color || nextColor(),
coords: st.coords,
_statsCache: null,
groupId: st.groupId || null,
groupName: st.groupName || null,
segmentLabel: st.segmentLabel || null
};
tracks.push(t);
}
if (tracks.length) {
activeTrackId = tracks[tracks.length - 1].id;
setTrackPanelVisible(true);
renderTrackList();
}
initTrackEdit(mapRef, stateRef, updateProfile2, {
findTrack: (id) => tracks.find((tr) => tr.id === id),
getActiveTrack,
createNewTrack,
deleteTrack,
removeIncompleteNewTrack,
onTrackCoordsChanged,
invalidateAndRefresh,
refreshTrackSource,
renderTrackList,
updateVertexHighlight,
trackPtsLayerId,
elevationAt: elevationAt2
});
map2.on("data", (e) => {
if (e.sourceId === DEM_SOURCE_ID && e.dataType === "source" && tracks.length) {
invalidateAllTrackStats();
setTimeout(() => {
enrichAllTracks();
renderTrackList();
}, 200);
}
});
tracksBtn.addEventListener("click", () => {
setTrackPanelVisible(!trackPanel.classList.contains("visible"));
});
initIO({
createTrack,
getActiveTrack,
getTracks: () => tracks,
getWaypoints: () => waypoints,
addWaypoints,
fitToTrack
});
map2.on("load", () => {
mapReady = true;
ensureProfileHoverLayer();
ensureWaypointLayer();
for (const t of tracks) {
if (!map2.getSource(trackSourceId(t))) addTrackToMap(t);
}
});
}
var map2, state2, updateProfileFn2, tracks, waypoints, activeTrackId, trackColorIdx, mapReady, profileClosed, _saveTimer, tracksBtn, trackPanelShell, trackToolRow, trackPanel, trackPanelHeader, trackListEl, profileToggleBtn, PROFILE_HOVER_SOURCE_ID, PROFILE_HOVER_LAYER_ID, HOVER_INSERT_SOURCE_ID2, HOVER_INSERT_LAYER_ID, WAYPOINT_SOURCE_ID, WAYPOINT_CIRCLE_LAYER_ID, WAYPOINT_LABEL_LAYER_ID, collapsedGroups;
var init_tracks = __esm({
"tracks.js"() {
init_utils();
init_constants();
init_dem();
init_track_edit();
init_io();
init_persist();
updateProfileFn2 = () => {
};
tracks = [];
waypoints = [];
activeTrackId = null;
trackColorIdx = 0;
mapReady = false;
profileClosed = false;
_saveTimer = 0;
PROFILE_HOVER_SOURCE_ID = "profile-hover-point";
PROFILE_HOVER_LAYER_ID = "profile-hover-point-layer";
HOVER_INSERT_SOURCE_ID2 = "hover-insert-point";
HOVER_INSERT_LAYER_ID = "hover-insert-point-layer";
WAYPOINT_SOURCE_ID = "waypoints";
WAYPOINT_CIRCLE_LAYER_ID = "waypoint-circles";
WAYPOINT_LABEL_LAYER_ID = "waypoint-labels";
collapsedGroups = /* @__PURE__ */ new Set();
}
});
// profile.js
function clearProfileHoverVertex() {
hoveredProfileTrackId = null;
hoveredProfileVertexIndex = null;
const src = map3.getSource(tracksState.PROFILE_HOVER_SOURCE_ID);
if (src) src.setData({ type: "FeatureCollection", features: [] });
}
function ensureVertexInView(lngLat) {
const canvas = map3.getCanvas();
const trackPanel2 = document.getElementById("track-panel");
const padding = {
top: 50,
right: trackPanel2.classList.contains("visible") ? 220 : 60,
bottom: profilePanel.classList.contains("visible") ? 170 : 60,
left: 60
};
const point = map3.project([lngLat.lng, lngLat.lat]);
const withinX = point.x >= padding.left && point.x <= canvas.clientWidth - padding.right;
const withinY = point.y >= padding.top && point.y <= canvas.clientHeight - padding.bottom;
if (withinX && withinY) return;
map3.easeTo({
center: [lngLat.lng, lngLat.lat],
duration: 1e3,
essential: true,
padding
});
}
function setProfileHoverVertex(index) {
const t = tracksState.getActiveTrack();
if (!t || index == null || index < 0 || index >= t.coords.length) {
clearProfileHoverVertex();
hideCursorTooltip();
return;
}
if (hoveredProfileTrackId === t.id && hoveredProfileVertexIndex === index) return;
hoveredProfileTrackId = t.id;
hoveredProfileVertexIndex = index;
const coord = t.coords[index];
const src = map3.getSource(tracksState.PROFILE_HOVER_SOURCE_ID);
if (src) {
src.setData({
type: "FeatureCollection",
features: [{
type: "Feature",
geometry: { type: "Point", coordinates: [coord[0], coord[1]] },
properties: { color: t.color }
}]
});
}
ensureVertexInView({ lng: coord[0], lat: coord[1] });
if (state3.cursorInfoMode === "cursor") {
const result = queryLoadedElevationAtLngLat(map3, { lng: coord[0], lat: coord[1] });
if (result) {
const pt = map3.project([coord[0], coord[1]]);
const rect = map3.getCanvas().getBoundingClientRect();
showCursorTooltipAt(state3, rect.left + pt.x, rect.top + pt.y, `${result.elevation.toFixed(0)} m`, result.slopeDeg != null ? `${result.slopeDeg.toFixed(0)}\xB0` : "n/a");
}
}
}
function destroyProfileChart() {
clearProfileHoverVertex();
if (profileChart) {
profileChart.destroy();
profileChart = null;
}
}
function syncBottomRightOffset() {
document.body.classList.toggle("profile-open", profilePanel.classList.contains("visible"));
}
function closeProfile(markClosed) {
profilePanel.classList.remove("visible");
if (markClosed) tracksState.profileClosed = true;
destroyProfileChart();
syncBottomRightOffset();
tracksState.syncProfileToggleButton();
}
function computeProfile(coords) {
const distances = [0];
const elevations = [];
const slopes = [];
const terrainSlopes = [];
for (let i = 0; i < coords.length; i++) {
if (i > 0) distances.push(distances[i - 1] + haversineKm(coords[i - 1], coords[i]));
elevations.push(coords[i][2] != null ? coords[i][2] : null);
const terrainSample = queryLoadedElevationAtLngLat(map3, { lng: coords[i][0], lat: coords[i][1] });
terrainSlopes.push(terrainSample && Number.isFinite(terrainSample.slopeDeg) ? terrainSample.slopeDeg : null);
if (i > 0) {
const dh = coords[i][2] != null && coords[i - 1][2] != null ? coords[i][2] - coords[i - 1][2] : null;
const dd = (distances[i] - distances[i - 1]) * 1e3;
slopes.push(dh != null && dd > 0 ? Math.sign(dh) * Math.atan2(Math.abs(dh), dd) * 180 / Math.PI : null);
} else {
slopes.push(null);
}
}
return { distances, elevations, slopes, terrainSlopes };
}
function updateProfile() {
const t = tracksState.getActiveTrack();
if (!t || t.coords.length < 2 || tracksState.profileClosed) {
if (!t || t.coords.length < 2) {
closeProfile(false);
}
tracksState.syncProfileToggleButton();
return;
}
const { distances, elevations, slopes, terrainSlopes } = computeProfile(t.coords);
const labels = distances.map((d) => d.toFixed(2));
destroyProfileChart();
profilePanel.classList.add("visible");
syncBottomRightOffset();
tracksState.syncProfileToggleButton();
profileChart = new Chart(profileCanvas, {
type: "line",
data: {
labels,
datasets: [
{
label: "Elevation (m)",
data: elevations,
borderColor: "#4a90d9",
backgroundColor: "rgba(74,144,217,0.12)",
fill: true,
pointRadius: 0,
borderWidth: 1.5,
tension: 0.3,
yAxisID: "yEle",
spanGaps: true
},
{
label: "Track slope (\xB0)",
data: slopes,
borderColor: "#e53935",
pointRadius: 0,
borderWidth: 1,
tension: 0.3,
yAxisID: "ySlope",
spanGaps: true
},
{
label: "Terrain slope (\xB0)",
data: terrainSlopes,
borderColor: "#7c3aed",
pointRadius: 0,
borderWidth: 1,
borderDash: [5, 3],
tension: 0.3,
yAxisID: "ySlope",
spanGaps: true
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
onHover: (_event, activeElements) => {
if (activeElements && activeElements.length) setProfileHoverVertex(activeElements[0].index);
else clearProfileHoverVertex();
},
interaction: { mode: "index", intersect: false },
plugins: {
legend: { display: true, position: "top", labels: { boxWidth: 12, font: { size: 10 } } },
annotation: {
annotations: {
zeroLine: {
type: "line",
yMin: 0,
yMax: 0,
yScaleID: "ySlope",
borderColor: "rgba(0,0,0,0.25)",
borderWidth: 1,
borderDash: [4, 3]
}
}
},
tooltip: {
callbacks: {
title: (items) => items[0] ? `${items[0].label} km` : ""
}
}
},
scales: {
x: {
display: true,
title: { display: true, text: "km", font: { size: 10 } },
ticks: { font: { size: 9 }, maxTicksLimit: 10 }
},
yEle: {
type: "linear",
position: "left",
title: { display: true, text: "m", font: { size: 10 } },
ticks: { font: { size: 9 } },
grid: { drawOnChartArea: true }
},
ySlope: {
type: "linear",
position: "right",
title: { display: true, text: "\xB0", font: { size: 10 } },
ticks: { font: { size: 9 } },
grid: { drawOnChartArea: false }
}
}
}
});
}
function getProfileChart() {
return profileChart;
}
function initProfile(mapRef, stateRef, tracksStateRef) {
map3 = mapRef;
state3 = stateRef;
tracksState = tracksStateRef;
document.getElementById("profile-close").addEventListener("click", () => {
closeProfile(true);
});
const profileToggleBtn2 = document.getElementById("profile-toggle-btn");
profileToggleBtn2.addEventListener("click", () => {
const t = tracksState.getActiveTrack();
if (!t || t.coords.length < 2) return;
if (profilePanel.classList.contains("visible") && !tracksState.profileClosed) {
closeProfile(true);
return;
}
tracksState.profileClosed = false;
updateProfile();
});
profileCanvas.addEventListener("mouseleave", () => {
clearProfileHoverVertex();
hideCursorTooltip();
});
}
var map3, state3, tracksState, profilePanel, profileCanvas, profileChart, hoveredProfileTrackId, hoveredProfileVertexIndex;
var init_profile = __esm({
"profile.js"() {
init_utils();
init_dem();
init_ui();
profilePanel = document.getElementById("profile-panel");
profileCanvas = document.getElementById("profile-canvas");
profileChart = null;
hoveredProfileTrackId = null;
hoveredProfileVertexIndex = null;
}
});
// main.js
var require_main = __commonJS({
"main.js"() {
init_constants();
init_state();
init_ui();
init_dem();
init_tracks();
init_profile();
init_io();
init_persist();
init_utils();
var state4 = createStore(STATE_DEFAULTS);
function buildDebugGridGeoJSON(map5) {
const visible = getVisibleTriplesForMap(map5);
const features = [];
const dedupe = /* @__PURE__ */ new Set();
for (const t of visible) {
const id = `${t.z}/${t.x}/${t.y}`;
if (dedupe.has(id)) continue;
dedupe.add(id);
const b = tileToLngLatBounds(t.x, t.y, t.z);
features.push({
type: "Feature",
properties: { id, z: t.z, x: t.x, y: t.y },
geometry: {
type: "Polygon",
coordinates: [[
[b.west, b.north],
[b.east, b.north],
[b.east, b.south],
[b.west, b.south],
[b.west, b.north]
]]
}
});
}
return { type: "FeatureCollection", features };
}
function updateDebugGridSource(map5) {
const src = map5.getSource("dem-debug-grid");
if (!src) return;
src.setData(buildDebugGridGeoJSON(map5));
}
function ensureDebugGridLayer(map5) {
if (!map5.getSource("dem-debug-grid")) {
map5.addSource("dem-debug-grid", {
type: "geojson",
data: { type: "FeatureCollection", features: [] }
});
}
if (!map5.getLayer("dem-debug-grid-line")) {
map5.addLayer({
id: "dem-debug-grid-line",
type: "line",
source: "dem-debug-grid",
layout: {
visibility: state4.showTileGrid ? "visible" : "none"
},
paint: {
"line-color": "#111111",
"line-width": 1,
"line-opacity": 0.8
}
});
}
}
var persisted = loadSettings();
if (persisted) {
for (const k of Object.keys(persisted)) {
if (persisted[k] !== void 0) state4[k] = persisted[k];
}
}
var initialView = parseHashParams();
if (initialView.basemap) {
state4.basemap = initialView.basemap;
}
state4.mode = initialView.mode;
state4.slopeOpacity = initialView.slopeOpacity;
state4.terrain3d = initialView.terrain3d;
state4.terrainExaggeration = initialView.terrainExaggeration;
document.getElementById("basemap").value = state4.basemap;
document.getElementById("mode").value = state4.mode;
document.getElementById("slopeOpacity").value = String(state4.slopeOpacity);
document.getElementById("slopeOpacityValue").textContent = state4.slopeOpacity.toFixed(2);
document.getElementById("terrain3d").checked = state4.terrain3d;
document.getElementById("terrainExaggeration").value = String(state4.terrainExaggeration);
document.getElementById("terrainExaggeration").disabled = !state4.terrain3d;
document.getElementById("terrainExaggerationValue").textContent = state4.terrainExaggeration.toFixed(2);
if (persisted) {
if (persisted.basemapOpacity != null) {
document.getElementById("basemapOpacity").value = String(state4.basemapOpacity);
document.getElementById("basemapOpacityValue").textContent = state4.basemapOpacity.toFixed(2);
}
if (persisted.hillshadeOpacity != null) {
document.getElementById("hillshadeOpacity").value = String(state4.hillshadeOpacity);
document.getElementById("hillshadeOpacityValue").textContent = state4.hillshadeOpacity.toFixed(2);
}
if (persisted.hillshadeMethod != null) {
document.getElementById("hillshadeMethod").value = state4.hillshadeMethod;
}
if (persisted.showContours != null) document.getElementById("showContours").checked = state4.showContours;
if (persisted.showOpenSkiMap != null) document.getElementById("showOpenSkiMap").checked = state4.showOpenSkiMap;
if (persisted.multiplyBlend != null) document.getElementById("multiplyBlend").checked = state4.multiplyBlend;
if (persisted.cursorInfoMode != null) document.getElementById("cursorInfoMode").value = state4.cursorInfoMode;
}
var _settingsSaveTimer = 0;
function scheduleSettingsSave() {
clearTimeout(_settingsSaveTimer);
_settingsSaveTimer = setTimeout(() => saveSettings(state4), 300);
}
var demContourSource = new mlcontour.DemSource({
url: "https://tiles.mapterhorn.com/{z}/{x}/{y}.webp",
encoding: "terrarium",
maxzoom: 12,
worker: true
});
demContourSource.setupMaplibre(maplibregl);
var map4 = new maplibregl.Map({
container: "map",
center: initialView.center,
zoom: initialView.zoom,
bearing: initialView.bearing,
pitch: initialView.pitch,
maxTileCacheZoomLevels: 20,
attributionControl: false,
style: {
version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
sources: {
contourSource: {
type: "vector",
tiles: [
demContourSource.contourProtocolUrl({
multiplier: 1,
overzoom: 1,
thresholds: {
10: [200, 1e3],
11: [100, 500],
12: [100, 500],
13: [50, 200],
14: [20, 100],
16: [10, 50]
},
elevationKey: "ele",
levelKey: "level",
contourLayer: "contours"
})
],
maxzoom: 16
},
osm: {
type: "raster",
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
tileSize: 256,
attribution: "&copy; OpenStreetMap contributors"
},
otm: {
type: "raster",
tiles: [
"https://a.tile.opentopomap.org/{z}/{x}/{y}.png",
"https://b.tile.opentopomap.org/{z}/{x}/{y}.png",
"https://c.tile.opentopomap.org/{z}/{x}/{y}.png"
],
tileSize: 256,
attribution: "&copy; OpenStreetMap contributors, OpenTopoMap"
},
ignplan: {
type: "raster",
tiles: ["https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/png"],
tileSize: 256,
attribution: "&copy; IGN France"
},
swisstopo: {
type: "vector",
tiles: ["https://vectortiles.geo.admin.ch/tiles/ch.swisstopo.base.vt/v1.0.0/{z}/{x}/{y}.pbf"],
attribution: "&copy; swisstopo"
},
openskimap: {
type: "vector",
tiles: ["https://tiles.openskimap.org/openskimap/{z}/{x}/{y}.pbf"],
attribution: "&copy; OpenSkiMap, OpenStreetMap contributors"
},
kartverket: {
type: "raster",
tiles: ["https://cache.kartverket.no/v1/wmts/1.0.0/topo/default/webmercator/{z}/{y}/{x}.png"],
tileSize: 256,
attribution: "&copy; Kartverket"
},
dem: {
type: "raster-dem",
tiles: ["https://tiles.mapterhorn.com/{z}/{x}/{y}.webp"],
tileSize: 512,
maxzoom: DEM_MAX_Z,
encoding: "terrarium"
}
},
layers: [
{
id: "basemap-osm",
type: "raster",
source: "osm",
paint: { "raster-opacity": basemapOpacityExpr(1) }
},
{
id: "basemap-otm",
type: "raster",
source: "otm",
layout: { visibility: "none" },
paint: { "raster-opacity": basemapOpacityExpr(1) }
},
{
id: "basemap-ign",
type: "raster",
source: "ignplan",
layout: { visibility: "none" },
paint: { "raster-opacity": basemapOpacityExpr(1) }
},
{
id: "basemap-kartverket",
type: "raster",
source: "kartverket",
layout: { visibility: "none" },
paint: { "raster-opacity": basemapOpacityExpr(1) }
},
{
id: "basemap-swiss-landcover",
type: "fill",
source: "swisstopo",
"source-layer": "landcover",
layout: { visibility: "none" },
paint: { "fill-color": "#dce7cf", "fill-opacity": basemapOpacityExpr(0.85) }
},
{
id: "basemap-swiss-water",
type: "fill",
source: "swisstopo",
"source-layer": "water",
layout: { visibility: "none" },
paint: { "fill-color": "#b7d7ff", "fill-opacity": basemapOpacityExpr(0.95) }
},
{
id: "basemap-swiss-transport",
type: "line",
source: "swisstopo",
"source-layer": "transportation",
layout: { visibility: "none" },
paint: {
"line-color": "#7a7a7a",
"line-width": ["interpolate", ["linear"], ["zoom"], 5, 0.2, 14, 1.8],
"line-opacity": basemapOpacityExpr(0.9)
}
},
{
id: "basemap-swiss-boundary",
type: "line",
source: "swisstopo",
"source-layer": "boundary",
layout: { visibility: "none" },
paint: {
"line-color": "#7f4b63",
"line-width": ["interpolate", ["linear"], ["zoom"], 5, 0.25, 14, 1.25],
"line-opacity": basemapOpacityExpr(0.75)
}
},
{
id: "basemap-swiss-label",
type: "symbol",
source: "swisstopo",
"source-layer": "place",
layout: {
visibility: "none",
"text-field": ["coalesce", ["get", "name"], ["get", "name_de"], ["get", "name_fr"], ["get", "name_it"], ""],
"text-size": ["interpolate", ["linear"], ["zoom"], 6, 10, 14, 13]
},
paint: {
"text-color": "#2e2e2e",
"text-opacity": basemapOpacityExpr(0.9),
"text-halo-color": "#ffffff",
"text-halo-width": 1
}
},
{
id: "basemap-ski-areas",
type: "fill",
source: "openskimap",
"source-layer": "skiareas",
layout: { visibility: "none" },
paint: { "fill-color": "#dff1ff", "fill-opacity": basemapOpacityExpr(0.35) }
},
{
id: "basemap-ski-runs",
type: "line",
source: "openskimap",
"source-layer": "runs",
layout: { visibility: "none" },
paint: {
"line-color": "#0d7cff",
"line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.9, 14, 2.6],
"line-opacity": basemapOpacityExpr(0.95)
}
},
{
id: "basemap-ski-lifts",
type: "line",
source: "openskimap",
"source-layer": "lifts",
layout: { visibility: "none" },
paint: {
"line-color": "#121212",
"line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.8, 14, 2],
"line-opacity": basemapOpacityExpr(0.9)
}
},
{
id: "basemap-ski-spots",
type: "symbol",
source: "openskimap",
"source-layer": "spots",
layout: {
visibility: "none",
"text-field": ["coalesce", ["get", "name"], ""],
"text-size": ["interpolate", ["linear"], ["zoom"], 8, 10, 14, 12]
},
paint: {
"text-color": "#10243f",
"text-opacity": basemapOpacityExpr(0.9),
"text-halo-color": "#ffffff",
"text-halo-width": 1
}
},
{
id: "dem-loader",
type: "hillshade",
source: DEM_SOURCE_ID,
paint: {
"hillshade-method": state4.hillshadeMethod,
"hillshade-exaggeration": ["coalesce", ["global-state", "hillshadeOpacity"], 0.35],
"hillshade-shadow-color": "#000000",
"hillshade-highlight-color": "#ffffff",
"hillshade-accent-color": "#000000"
}
}
]
},
canvasContextAttributes: { antialias: true }
});
var navigationControl = new maplibregl.NavigationControl({
visualizePitch: true,
visualizeRoll: true,
showZoom: true,
showCompass: true
});
var geolocateControl = new maplibregl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: false,
showUserLocation: true,
showAccuracyCircle: false
});
var scaleControl = new maplibregl.ScaleControl({ unit: "metric", maxWidth: 120 });
map4.addControl(scaleControl, "bottom-right");
map4.addControl(new maplibregl.AttributionControl(), "bottom-right");
map4.addControl(navigationControl, "bottom-right");
map4.addControl(geolocateControl, "bottom-right");
var controlsPanel = document.getElementById("controls");
var controlsToggleBtn = document.getElementById("controls-toggle");
function syncControlsToggleLabel() {
controlsToggleBtn.textContent = controlsPanel.classList.contains("collapsed") ? "\u{1F30D} Settings \u25B8" : "\u{1F30D} Settings \u25BE";
}
function setControlsCollapsed(collapsed) {
controlsPanel.classList.toggle("collapsed", collapsed);
syncControlsToggleLabel();
}
controlsToggleBtn.addEventListener("click", () => {
setControlsCollapsed(!controlsPanel.classList.contains("collapsed"));
});
syncControlsToggleLabel();
var advancedToggle = document.getElementById("advanced-toggle");
var advancedSection = document.getElementById("advanced-section");
advancedToggle.addEventListener("click", () => {
const open = advancedSection.classList.toggle("open");
advancedToggle.querySelector(".arrow").classList.toggle("open", open);
});
map4.on("dragstart", () => {
setControlsCollapsed(true);
});
initSearch(map4);
initTracks(map4, state4, updateProfile);
var tracksState2 = getTracksState();
initProfile(map4, state4, tracksState2);
document.getElementById("mode").addEventListener("change", (e) => {
state4.mode = e.target.value;
updateLegend(state4, map4);
applyModeState(map4, state4);
syncViewToUrl(map4, state4);
map4.triggerRepaint();
scheduleSettingsSave();
});
document.getElementById("basemap").addEventListener("change", (e) => {
state4.basemap = e.target.value;
applyBasemapSelection(map4, state4, true);
syncViewToUrl(map4, state4);
map4.triggerRepaint();
scheduleSettingsSave();
});
document.getElementById("basemapOpacity").addEventListener("input", (e) => {
state4.basemapOpacity = Number(e.target.value);
document.getElementById("basemapOpacityValue").textContent = state4.basemapOpacity.toFixed(2);
setGlobalStatePropertySafe(map4, "basemapOpacity", state4.basemapOpacity);
map4.triggerRepaint();
scheduleSettingsSave();
});
document.getElementById("hillshadeOpacity").addEventListener("input", (e) => {
state4.hillshadeOpacity = Number(e.target.value);
document.getElementById("hillshadeOpacityValue").textContent = state4.hillshadeOpacity.toFixed(2);
setGlobalStatePropertySafe(map4, "hillshadeOpacity", state4.hillshadeOpacity);
map4.triggerRepaint();
scheduleSettingsSave();
});
document.getElementById("hillshadeMethod").addEventListener("change", (e) => {
state4.hillshadeMethod = e.target.value;
if (map4.getLayer("dem-loader")) {
map4.setPaintProperty("dem-loader", "hillshade-method", state4.hillshadeMethod);
}
map4.triggerRepaint();
scheduleSettingsSave();
});
document.getElementById("slopeOpacity").addEventListener("input", (e) => {
state4.slopeOpacity = Number(e.target.value);
document.getElementById("slopeOpacityValue").textContent = state4.slopeOpacity.toFixed(2);
applyModeState(map4, state4);
syncViewToUrl(map4, state4);
map4.triggerRepaint();
scheduleSettingsSave();
});
document.getElementById("showContours").addEventListener("change", (e) => {
state4.showContours = Boolean(e.target.checked);
applyContourVisibility(map4, state4);
scheduleSettingsSave();
});
document.getElementById("showOpenSkiMap").addEventListener("change", (e) => {
state4.showOpenSkiMap = Boolean(e.target.checked);
applyOpenSkiMapOverlay(map4, state4);
scheduleSettingsSave();
});
document.getElementById("showTileGrid").addEventListener("change", (e) => {
state4.showTileGrid = Boolean(e.target.checked);
if (map4.getLayer("dem-debug-grid-line")) {
map4.setLayoutProperty("dem-debug-grid-line", "visibility", state4.showTileGrid ? "visible" : "none");
}
if (state4.showTileGrid) {
updateDebugGridSource(map4);
}
map4.triggerRepaint();
});
document.getElementById("multiplyBlend").addEventListener("change", (e) => {
state4.multiplyBlend = Boolean(e.target.checked);
map4.triggerRepaint();
scheduleSettingsSave();
});
document.getElementById("cursorInfoMode").addEventListener("change", (e) => {
state4.cursorInfoMode = e.target.value;
updateCursorInfoVisibility(state4);
scheduleSettingsSave();
});
document.getElementById("terrain3d").addEventListener("change", (e) => {
state4.terrain3d = Boolean(e.target.checked);
document.getElementById("terrainExaggeration").disabled = !state4.terrain3d;
applyTerrainState(map4, state4);
syncViewToUrl(map4, state4);
map4.triggerRepaint();
scheduleSettingsSave();
});
document.getElementById("terrainExaggeration").addEventListener("input", (e) => {
state4.terrainExaggeration = Number(e.target.value);
document.getElementById("terrainExaggerationValue").textContent = state4.terrainExaggeration.toFixed(2);
if (state4.terrain3d) {
applyTerrainState(map4, state4);
}
syncViewToUrl(map4, state4);
map4.triggerRepaint();
scheduleSettingsSave();
});
document.getElementById("clear-data-btn").addEventListener("click", () => {
if (confirm("Clear all saved tracks and settings?")) {
clearAll();
location.reload();
}
});
map4.getCanvas().addEventListener("mousedown", (e) => {
if (e.metaKey && !e.ctrlKey && e.button === 0) {
const synth = new MouseEvent("mousedown", {
bubbles: true,
cancelable: true,
clientX: e.clientX,
clientY: e.clientY,
button: e.button,
buttons: e.buttons,
ctrlKey: true,
shiftKey: e.shiftKey,
altKey: e.altKey,
metaKey: false
});
e.target.dispatchEvent(synth);
e.preventDefault();
e.stopPropagation();
}
}, { capture: true });
updateLegend(state4, map4);
updateStatus(state4);
updateCursorInfoVisibility(state4);
map4.on("load", () => {
ensureDebugGridLayer(map4);
setGlobalStatePropertySafe(map4, "basemapOpacity", state4.basemapOpacity);
setGlobalStatePropertySafe(map4, "hillshadeOpacity", state4.hillshadeOpacity);
applyBasemapSelection(map4, state4);
applyOpenSkiMapOverlay(map4, state4);
applyTerrainState(map4, state4);
updateDebugGridSource(map4);
syncViewToUrl(map4, state4);
map4.on("moveend", () => {
syncViewToUrl(map4, state4);
if (state4.showTileGrid) updateDebugGridSource(map4);
});
map4.on("zoom", () => {
if (state4.mode === "slope+relief") {
computeEffectiveSlopeOpacity(state4, map4);
map4.triggerRepaint();
}
});
map4.on("zoomend", () => {
syncViewToUrl(map4, state4);
if (state4.showTileGrid) updateDebugGridSource(map4);
if (state4.mode === "slope+relief") updateLegend(state4, map4);
});
map4.on("rotateend", () => {
syncViewToUrl(map4, state4);
});
map4.on("pitchend", () => {
syncViewToUrl(map4, state4);
});
const hybridLayer = createHybridBorderLayer(state4, getVisibleTriplesForMap, () => updateStatus(state4));
map4.addLayer(hybridLayer);
map4.addLayer({
id: "dem-color-relief",
type: "color-relief",
source: DEM_SOURCE_ID,
layout: {
visibility: state4.mode === "color-relief" || state4.mode === "slope+relief" ? "visible" : "none"
},
paint: {
"color-relief-opacity": state4.slopeOpacity,
"color-relief-color": ANALYSIS_COLOR["color-relief"]
}
});
applyModeState(map4, state4);
map4.addLayer({
id: "contours",
type: "line",
source: "contourSource",
"source-layer": "contours",
paint: {
"line-opacity": 0.2,
"line-width": ["match", ["get", "level"], 1, 1, 0.5]
}
});
map4.addLayer({
id: "contour-text",
type: "symbol",
source: "contourSource",
"source-layer": "contours",
filter: [">", ["get", "level"], 0],
paint: {
"text-halo-color": "white",
"text-halo-width": 1
},
layout: {
"symbol-placement": "line",
"text-size": 10,
"text-field": ["concat", ["number-format", ["get", "ele"], {}], "m"],
"text-font": ["Noto Sans Bold"]
}
});
applyContourVisibility(map4, state4);
const isMobile2 = "ontouchstart" in window || navigator.maxTouchPoints > 0;
const mobileCrosshair = document.getElementById("mobile-crosshair");
let cursorRaf = 0;
let lastPointerLngLat = null;
let lastPointerScreenXY = null;
const updateCursorElevation = () => {
cursorRaf = 0;
if (!lastPointerLngLat) {
setCursorInfo(state4, "n/a");
hideCursorTooltip();
return;
}
const result = queryLoadedElevationAtLngLat(map4, lastPointerLngLat);
if (!result) {
setCursorInfo(state4, "no loaded tile");
if (lastPointerScreenXY) showCursorTooltipAt(state4, lastPointerScreenXY.x, lastPointerScreenXY.y, "no tile", "n/a");
return;
}
const eleText = `${result.elevation.toFixed(0)} m`;
const slopeStr = result.slopeDeg != null ? `${result.slopeDeg.toFixed(0)}\xB0` : "n/a";
setCursorInfo(state4, eleText, slopeStr);
if (lastPointerScreenXY) showCursorTooltipAt(state4, lastPointerScreenXY.x, lastPointerScreenXY.y, eleText, slopeStr);
};
map4.on("mousemove", (e) => {
lastPointerLngLat = e.lngLat;
lastPointerScreenXY = { x: e.originalEvent.clientX, y: e.originalEvent.clientY };
if (!cursorRaf) cursorRaf = requestAnimationFrame(updateCursorElevation);
});
map4.on("mouseout", () => {
lastPointerLngLat = null;
lastPointerScreenXY = null;
if (!cursorRaf) cursorRaf = requestAnimationFrame(updateCursorElevation);
});
if (isMobile2) {
map4.on("click", (e) => {
if (tracksState2.editingTrackId) return;
const result = queryLoadedElevationAtLngLat(map4, e.lngLat);
if (state4.cursorInfoMode !== "no") {
mobileCrosshair.style.left = e.originalEvent.clientX + "px";
mobileCrosshair.style.top = e.originalEvent.clientY + "px";
mobileCrosshair.classList.add("visible");
if (state4.cursorInfoMode === "cursor" && result) {
const eleText = `${result.elevation.toFixed(0)} m`;
const slopeStr = result.slopeDeg != null ? `${result.slopeDeg.toFixed(0)}\xB0` : "n/a";
showCursorTooltipAt(state4, e.originalEvent.clientX, e.originalEvent.clientY, eleText, slopeStr);
}
}
});
map4.on("dragstart", () => {
mobileCrosshair.classList.remove("visible");
hideCursorTooltip();
});
}
let borderFixTimer = 0;
const flushInternalTextures = () => {
borderFixTimer = 0;
const gl = map4.painter && map4.painter.context && map4.painter.context.gl;
if (gl) {
for (const entry of hybridLayer.internalTextures.values()) {
if (entry.texture) gl.deleteTexture(entry.texture);
}
}
hybridLayer.internalTextures.clear();
map4.triggerRepaint();
};
map4.on("data", (e) => {
if (e.sourceId === DEM_SOURCE_ID && e.dataType === "source") {
if (!borderFixTimer) {
borderFixTimer = setTimeout(flushInternalTextures, 100);
}
}
});
});
var hashNavInProgress = false;
window.addEventListener("hashchange", () => {
if (hashNavInProgress) return;
const p = parseHashParams();
hashNavInProgress = true;
map4.jumpTo({ center: p.center, zoom: p.zoom });
state4.basemap = p.basemap || "osm";
state4.mode = p.mode;
state4.slopeOpacity = p.slopeOpacity;
state4.terrain3d = p.terrain3d;
state4.terrainExaggeration = p.terrainExaggeration;
document.getElementById("basemap").value = state4.basemap;
document.getElementById("mode").value = state4.mode;
document.getElementById("slopeOpacity").value = String(state4.slopeOpacity);
document.getElementById("slopeOpacityValue").textContent = state4.slopeOpacity.toFixed(2);
document.getElementById("terrain3d").checked = state4.terrain3d;
document.getElementById("terrainExaggeration").value = String(state4.terrainExaggeration);
document.getElementById("terrainExaggeration").disabled = !state4.terrain3d;
document.getElementById("terrainExaggerationValue").textContent = state4.terrainExaggeration.toFixed(2);
updateLegend(state4, map4);
map4.setBearing(p.bearing);
map4.setPitch(p.pitch);
applyBasemapSelection(map4, state4, true);
applyModeState(map4, state4);
applyTerrainState(map4, state4);
hashNavInProgress = false;
syncViewToUrl(map4, state4);
map4.triggerRepaint();
});
map4.on("error", (e) => {
console.error("Map error:", e && e.error ? e.error.message : e);
});
Object.defineProperties(window, {
mapReady: { get() {
return tracksState2.mapReady;
} },
map: { get() {
return map4;
} },
tracks: { get() {
return tracksState2.tracks;
} },
waypoints: { get() {
return tracksState2.waypoints;
} },
activeTrackId: { get() {
return tracksState2.activeTrackId;
} },
editingTrackId: { get() {
return tracksState2.editingTrackId;
} },
selectedVertexIndex: { get() {
return tracksState2.selectedVertexIndex;
} },
insertAfterIdx: { get() {
return tracksState2.insertAfterIdx;
} },
mobileFriendlyMode: { get() {
return tracksState2.mobileFriendlyMode;
} },
importFileContent: { get() {
return importFileContent;
} },
profileChart: { get() {
return getProfileChart();
} },
profileClosed: { get() {
return tracksState2.profileClosed;
} }
});
}
});
require_main();
})();
</script>
</body>
</html>

Slope viewer & editor — Feature summary

Map & Visualization

  • Analysis modesSlope + Color relief (default), Slope, Aspect, Color relief, and an empty none mode that disables the DEM analysis overlay entirely
  • Slope + Color relief mode — hybrid mode showing slope analysis at zoom ≥ 14 and color-relief below, with a one-zoom-level crossfade
  • Slope / Aspect overlay — custom WebGL layer using Horn's algorithm on raster-DEM tiles, with configurable opacity and color ramp
  • Color relief — DEM color ramp mode rendered with the built-in color-relief layer
  • Hillshade — multiple methods (standard, basic, combined, multidirectional, igor), configurable opacity
  • Contour lines — generated client-side from DEM via maplibre-contour, auto-shown for OSM only (other basemaps have their own contours), manual toggle available
  • Multiply blend — optional compositing mode for the DEM analysis overlay on the basemap
  • 3D terrain — toggle with configurable exaggeration

Basemaps

  • OSM (default), OTM, IGN plan (FR), SwissTopo vector, Kartverket topo (NO)
  • Basemap opacity — slider for the visible basemap stack
  • Auto fly-to — selecting a regional basemap outside its supported area recenters the view
  • URL persistence — center, zoom, basemap, mode, opacity, terrain state, bearing, and pitch are encoded in the URL hash

Overlays

  • OpenSkiMap — independent checkbox overlay (ski areas, runs, lifts, spots) on top of any basemap
  • DEM tile grid — debug overlay toggle for visible DEM tile coverage

Track Editor

  • Drag & drop import — GPX (tracks, segments, routes, waypoints with names) and GeoJSON files, with visual drop overlay; also supports dropping directories
  • Directory import — progressive support: File System Access API (Chrome/Edge) for read+write, <input webkitdirectory> fallback for read-only, drag & drop directory via webkitGetAsEntry
  • Top-right track workspace — compact floating panel for track management and export
  • Draw mode — pen button in the track header; creates a new track and enters edit mode; click to add vertices, double-click or Escape to finish; vertices can be dragged during editing
  • Track list button state — pin button is greyed out when there are no tracks and becomes a close button while the track panel is open
  • Multi-track management — color-coded tracks with selection, deletion (with confirmation), export actions, and active-track emphasis
  • Select vs Edit — selecting a track widens the line and shows the profile; clicking the edit button (✎) enters edit mode with fully interactive vertices. No separate draw mode — editing new and existing tracks uses the same unified editing state.
  • Vertex selection — clicking a vertex (desktop) or tapping it (mobile) selects it with blue highlight; an on-map "+" popup appears next to the selected vertex
  • Insert-here — clicking the on-map "+" popup toggles insert-after mode where new points are inserted between the selected vertex and the next one
  • Insert preview — dashed line shows where the next point will connect: from last point (append) or between neighbouring vertices (insert-after mode)
  • Track stats — total distance (km), elevation gain (↑), loss (↓), point count, average slope, and max slope for the active track
  • Elevation enrichment — all track points (imported and drawn) are enriched from the same DEM source and re-enriched when new DEM tiles load
  • Track markers — green start / red end dots; mid-point vertices shown only in edit mode
  • Smart hover-insert (desktop) — when cursor is near the track line between vertices, a single grey marker appears at the closest point; clicking and dragging inserts a new vertex
  • Ctrl/Shift/Meta+click delete — remove individual track vertices (edit mode only)
  • Desktop vertex editing — drag vertices to reposition (works in unified edit mode)
  • Mobile vertex editing — mobile-friendly mode is default on mobile (📱 toggle); crosshair at center, tap inserts at center, tap vertex then pan to reposition. Desktop-style mode also available (tap=click, long-press-drag=move vertex). On localhost, the 📱 toggle is shown on desktop for debugging.
  • Delete last point — 🗑️ button in toolbar to remove the last point; also Ctrl/Cmd+Z
  • Export — active track as GPX or GeoJSON; all tracks as GPX preserving group structure (grouped → one <trk> per group with <trkseg> per segment); includes <wpt> elements
  • Directory export — 'Save to folder…' button for File System Access browsers; writes one GPX per track
  • GPX waypoints<wpt> elements parsed from GPX via gpxjs; rendered as amber circles with text labels on the map; included in 'Export All GPX'
  • Two-level nesting — multi-segment GPX tracks import as grouped tracks; panel shows collapsible group header with aggregate stats and nested segments
  • localStorage persistence — tracks and settings auto-save to localStorage with 300ms debounce; restored on page reload; 'Clear saved data' button in advanced settings

Profile

  • Elevation profile — bottom panel showing elevation (m), track slope (°), and terrain slope (°) vs distance (km), with dual Y-axes and a zero-line
  • Reopenable profile — the track panel includes a profile toggle so closing the chart is not terminal
  • Profile-to-map hover linkage — hovering the profile highlights the corresponding track vertex on the map and shows cursor tooltip at the vertex
  • Hover pan assist — if the hovered vertex is out of view, the map pans to bring it back on screen

UX

  • Settings toggle — top-left 🌍 Settings button with auto-collapse when you start dragging the map
  • Elevation & slope display — configurable via dropdown: At cursor (floating tooltip near pointer, default), Corner (fixed in legend panel), No (hidden)
  • Mobile crosshair — tapping the map on mobile shows a crosshair at tap location with elevation/slope info; disappears on pan
  • Mobile draw crosshair — entering draw mode on mobile shows a center cross and a toast hint
  • Track panel header layout — when open, the header row is Tracks, Profile, draw button, close button; track details stay below
  • Panel styling — controls, legend, profile, and the open track panel share the same translucent blurred panel surface
  • Bottom-right controls — native MapLibre bottom-right stack with navigation, geolocate, ruler, and attribution
  • Legend behavior — dynamic color ramp for the current mode; in Mode: none, the legend collapses to cursor info only
  • Search — Nominatim geocoding with collapsible search box
  • Ctrl/Cmd+drag — tilt and rotate the map (same as right-click drag)
  • Toast notifications — ephemeral messages for mobile edition hints
  • PWA installable — manifest.json with icons at 192, 512, 180 (apple-touch), 32, 16 sizes; SVG favicon with mountain/slope theme
  • Localhost debug — mobile-friendly mode toggle (📱) shown on desktop when served from localhost

Module structure

  • slope.html — shell with HTML markup, CDN script tags, importmap for @we-gold/gpxjs, <link> to css/main.css, <script type="module" src="js/main.js">
  • css/main.css — all styles including track group nesting
  • js/main.js — entry point: creates map, imports all modules, wires settings event handlers, persistence, exposes window getters for tests
  • js/constants.js — pure data/config: DEM constants, analysis color ramps, basemap config, parsing/legend CSS helpers
  • js/dem.js — DEM tile processing, elevation sampling, WebGL hybrid border layer with GLSL shaders
  • js/ui.js — basemap/contour/terrain apply functions, legend, cursor tooltip, URL hash parsing/sync, Nominatim search
  • js/tracks.js — track data model, CRUD, map sources/layers, stats, panel UI (with group rendering), waypoint layer
  • js/track-edit.js — interactive track editing: vertex click/drag, insert popup, hover-insert, mobile editing, keyboard shortcuts, draw/undo buttons
  • js/io.js — import/export (GPX via gpxjs, GeoJSON), drag-drop, directory import/export, file generation
  • js/persist.js — localStorage persistence for tracks and settings (thin wrapper, no deps)
  • js/profile.js — Chart.js elevation profile, profile-to-map hover linkage
  • js/state.js — reactive Proxy store (createStore) + STATE_DEFAULTS
  • js/utils.js — pure utility functions (haversine, tile math, Terrarium codec, color utils, file download)

Dependency flow

  • constants.jsutils.js (pure, no DOM)
  • dem.jsutils.js, constants.js (no DOM except for fallback tile fetch)
  • ui.jsconstants.js, utils.js (DOM access for settings UI)
  • persist.js — standalone (localStorage only)
  • io.jsutils.js, @we-gold/gpxjs (GPX parsing/serialization)
  • track-edit.jsui.js (cursor tooltip)
  • tracks.jsutils.js, constants.js, dem.js, track-edit.js, io.js, persist.js
  • profile.jsutils.js, dem.js, ui.js (Chart.js + DOM)
  • main.js ← all modules (orchestrator)
  • state.js — standalone, imported by main.js which creates the store and passes it to modules

Technical gotchas

  • Contour initialization order — contour visibility must be re-applied after the contour layers are added, otherwise first-load state can disagree with the checkbox
  • Contour/basemap coupling — contour lines are auto-enabled only for OSM; switching basemaps intentionally resets the contour checkbox unless you change the logic
  • Mode: none behavior — empty mode disables the custom DEM analysis render path and hides the legend ramp/labels, but keeps cursor info visible
  • Color relief split pathcolor-relief is rendered via a separate MapLibre layer, not the custom WebGL analysis layer used for slope/aspect; in slope+relief mode both layers are active with zoom-dependent opacity expressions providing the crossfade
  • Slope + Color relief mode — this hybrid mode uses a threshold zoom ≥ SLOPE_RELIEF_CROSSFADE_Z ; color-relief opacity uses a MapLibre zoom interpolation expression, while the WebGL slope opacity is pre-computed in state.effectiveSlopeOpacity (via computeEffectiveSlopeOpacity) so render() stays mode-agnostic; legend switches dynamically at the zoom threshold
  • Track button statetracks-btn must be explicitly synced on startup so the disabled state matches the empty track list before any interaction
  • Native attribution control — when adding attribution manually in the bottom-right stack, the map must be created with attributionControl: false to avoid duplicate attribution UI
  • editingTrackId vs activeTrackIdactiveTrackId controls selection (wider line, profile); editingTrackId controls which track's vertices are interactive; when creating a new track via the draw button, editingIsNewTrack is set for auto-cleanup
  • Hover-insert layer — a separate GeoJSON source (hover-insert-point) holds at most one feature for the smart insert marker, avoiding re-rendering the full track GeoJSON on every mousemove

Layer Z-Order (bottom to top)

  1. Basemap
  2. OpenSkiMap overlay
  3. Hillshade
  4. DEM analysis overlay (Slope / Aspect) or Color relief
  5. Contour lines
  6. Track lines, vertices, hover-insert marker, and profile-hover marker
  7. Waypoint circles and labels

Detailed behaviour

Startup

  1. Parse URL hash for lng, lat, zoom, basemap, mode, opacity, terrain, exaggeration, bearing, pitch. Missing keys fall back to Mont Blanc area at zoom 12, slope mode, 0.45 opacity.
  2. Load persisted settings from localStorage (URL hash takes priority over persisted values).
  3. Build the MapLibre map with all sources (OSM, OTM, IGN, SwissTopo vector, Kartverket, OpenSkiMap, DEM raster-dem, contour vector tiles).
  4. Style layers are defined inline: basemap raster/vector, OpenSkiMap fill/line/symbol, hillshade, color-relief, contours, and a custom WebGL layer for slope/aspect.
  5. On map.on('load'): apply basemap selection, terrain state, debug grid, global-state properties, add the custom hybrid border layer, contour layers, and wire up elevation sampling.
  6. Call updateCursorInfoVisibility() to set initial cursor-info display mode.

DEM analysis rendering

  • The custom WebGL layer dem-analysis-hybrid-border renders slope or aspect per visible tile. For each tile it checks MapLibre's internal DEM tile manager first (which has proper padded borders for derivative accuracy). If no internal tile exists, it fetches the raw DEM tile as a fallback, decodes Terrarium encoding, pads to a 514×514 Float32 array, and backfills border pixels from loaded neighbours.
  • Horn's 3×3 derivative kernel is computed in the fragment shader; the slope/aspect scalar is mapped to a step colour ramp uploaded as uniforms.
  • The color-relief mode uses a separate MapLibre built-in layer rather than the custom shader.

Cursor elevation & slope

  • At cursor (default): a small #cursor-tooltip div positioned at clientX+15, clientY+15 shows elevation and slope. Updated every frame via requestAnimationFrame. On profile hover, it repositions to the hovered vertex's screen coordinates.
  • Corner: the #cursor-info element inside the legend panel shows the values (original behaviour).
  • No: both hidden.
  • On mobile: a #mobile-crosshair is shown at the tap point with the tooltip. Hidden on drag.

Settings panel

Contains dropdowns and sliders for: Mode, Basemap, Basemap opacity, Hillshade opacity, Hillshade method, Analysis opacity, Show contour lines, OpenSkiMap overlay, Show DEM tile grid, Elevation & slope (cursor/corner/no), Multiply blend, Enable 3D terrain + exaggeration. Also shows internal/fallback tile counts.

Track editor — state model

  • tracks[] — array of {id, name, color, coords, _statsCache, groupId, groupName, segmentLabel}.
  • waypoints[] — array of {id, name, coords, sym, desc, comment}.
  • activeTrackId — currently selected track (wider line, profile shown, start/end markers).
  • editingTrackId — track whose vertices are fully interactive (mid vertices visible, drag/hover-insert enabled). Set when user clicks the ✎ edit button in the track list, or the ✏ draw button to create a new track. There is no separate draw mode — editing a new or existing track uses the same unified editing state.
  • editingIsNewTrack — boolean. True when editingTrackId was set by creating a new track via the draw button. Controls auto-cleanup on exit (new tracks with < 2 points are removed).
  • selectedVertexIndex — index of the currently selected vertex in the editing track. Visual feedback: larger blue circle with white stroke.
  • insertAfterIdx — when set, new points are inserted after this index instead of appended to the end. Activated via the on-map "+" popup next to the selected vertex.
  • mobileFriendlyMode — boolean. Default true on mobile. When enabled, shows crosshair-centered insertion and pan-to-move vertex editing. On localhost, the toggle is also shown on desktop for debugging.
  • insertPreviewLngLat — tracks cursor/center position for the insert preview dashed line.
  • insertPopupMarker — MapLibre marker showing the "+" button on the map next to the selected vertex.

Track selection vs editing (unified)

  • Select (click track name): sets activeTrackId. Line becomes 4px wide. Profile auto-opens. Only start/end markers visible.
  • Edit (click ✎ button on existing track, or ✏ button to create new): sets editingTrackId. All vertices visible. Map clicks add vertices (append or insert). Vertices are draggable. Double-click or Escape exits editing.
  • No separate draw mode — the ✏ button creates a new track and enters the same edit mode as ✎.

Vertex selection and insert-here

  • Clicking a vertex without dragging (desktop) or tapping a vertex (mobile) selects it. Selected vertex shown with blue highlight (radius 7, blue fill).
  • When a vertex is selected, an on-map "+" popup appears next to it (as a MapLibre marker, anchored left with offset). Clicking it toggles insert-after mode.
  • In insert-after mode, new clicks insert points after the selected vertex (and chain: each insert advances the insertion index).
  • Selecting a different vertex while in insert mode moves the insertion point.

Insert preview

  • A dashed line (via insert-preview-line source/layer) shows where the next click will connect.
  • In append mode: line from last track point to cursor/crosshair.
  • In insert-after mode: two lines from insertAfterIdx to cursor and from cursor to insertAfterIdx+1.
  • Updated on mousemove (desktop) or map.move (mobile-friendly crosshair mode).

Smart hover-insert (desktop)

  • On mousemove: for each segment of the editing track, project both endpoints to screen coords, compute the closest point on the segment to the cursor. Skip if the parameter t < 0.1 or > 0.9 (too close to a vertex). If the closest distance < 20px, show a single grey circle marker at that point via the hover-insert-point source.
  • On mousedown near that marker: insert a new vertex at the position, then immediately start drag for that vertex.

Mobile editing modes

  • Mobile-friendly mode (📱 button toggle, default on for mobile devices, shown on localhost for desktop debugging):
    • Crosshair shown at screen center; tapping anywhere inserts a point at the center position.
    • Tapping a vertex selects it (blue highlight) and enters pan-to-move: toast "Drag screen to move", subsequent pan repositions the vertex keeping it at the center.
    • Touch end confirms the move.
  • Desktop-style mode (📱 toggled off): tap = click (adds point or selects vertex), long-press (400ms) + drag = move vertex directly.

Delete last point

  • 🗑️ button in the toolbar (visible only when a track is being edited and has points). Removes the last coordinate.
  • Ctrl/Cmd+Z also removes the last point during editing.

Track deletion

  • confirm() dialog with the track name before removing.

Profile

  • Chart.js line chart with three datasets: elevation (left Y-axis, filled), track slope (right Y-axis, red), terrain slope (right Y-axis, dashed purple).
  • onHover callback calls setProfileHoverVertex(index) which places a circle on the map and, if cursor-info mode is cursor, shows the tooltip at the vertex's projected screen position.
  • Profile closes automatically when switching away from a 2-point track. Re-openable via the "Profile" button.

URL hash sync

  • syncViewToUrl writes lng, lat, zoom, basemap, mode, opacity, terrain, exaggeration, bearing, pitch to the hash on every moveend, zoomend, rotateend, pitchend.
  • hashchange event reads the hash back and updates map + state + UI controls.

GPX / GeoJSON import

  • Drag & drop, directory import, or programmatic. GPX parser uses @we-gold/gpxjs library for parsing, extracting tracks, routes, and waypoints. Multi-segment tracks are split back into separate grouped tracks using segment point counts from the XML. GeoJSON parser extracts LineString and MultiLineString geometries.
  • Imported tracks are elevation-enriched from the DEM source and fitted to bounds.
  • Each imported track stores _gpxParsed reference to the parsed GPX document for future round-trip export.
  • Waypoints from GPX <wpt> elements are stored in a global array and rendered as a map symbol layer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment