125 Commits

Author SHA1 Message Date
vcoppe fe8896e870 get styles from github 2026-05-27 20:17:49 +02:00
vcoppe c99517572e update images 2026-05-27 19:40:09 +02:00
vcoppe 0b7f30a7c4 remove maptiler layers 2026-05-27 18:52:15 +02:00
vcoppe fcfaf043c4 fix map localization 2026-05-26 06:28:32 +02:00
vcoppe 0393a330a6 fix loading initial style 2026-05-26 06:18:00 +02:00
vcoppe 2fdb58bc7d switch contour lines based on units 2026-05-25 16:50:28 +02:00
vcoppe c758bda1a9 update tiles endpoint 2026-05-25 15:48:23 +02:00
vcoppe 88e301e2a2 use maptiler satellite 2026-05-25 14:45:03 +02:00
vcoppe 161c664e08 borders 2026-05-25 11:26:49 +02:00
vcoppe a0408ec798 revert rivers 2026-05-25 11:00:55 +02:00
vcoppe 480d5586b9 inverse font color and halo 2026-05-25 10:58:23 +02:00
vcoppe 7b49018593 remove casings 2026-05-25 10:44:53 +02:00
vcoppe 4b447a9b9d revert some of the changes 2026-05-25 10:40:38 +02:00
vcoppe 443feb2bfa road opacities 2026-05-25 10:29:29 +02:00
vcoppe 8710101a78 remove filled areas 2026-05-25 09:59:54 +02:00
vcoppe 2c20148e64 start satellite style 2026-05-24 13:49:43 +02:00
vcoppe ffc0e84788 slightly bigger paths 2026-05-24 00:23:40 +02:00
vcoppe 2d2004e447 small color change 2026-05-24 00:16:55 +02:00
vcoppe 4a7cfc113d vector terrain 2026-05-24 00:13:26 +02:00
vcoppe f03ad5a0e9 colored track casing 2026-05-24 00:04:09 +02:00
vcoppe 7f5b83f9d8 only use surface for coloring 2026-05-23 20:13:47 +02:00
vcoppe 5a8f93e225 different colors depending on surface 2026-05-23 20:10:21 +02:00
vcoppe 668b0f6b23 clearer path names 2026-05-23 19:59:12 +02:00
vcoppe fc1eb5a408 highlight trails 2026-05-23 19:52:04 +02:00
vcoppe 561c7e22b3 first tweaks 2026-05-23 19:17:37 +02:00
vcoppe ce067d4ff0 add custom layer base from openfreemap (osm liberty) 2026-05-23 19:14:59 +02:00
vcoppe 7361b54255 only apply minify to selection 2026-05-21 20:27:22 +02:00
vcoppe 9e2334ca83 New Crowdin updates (#336)
* New translations en.json (Dutch)

* New translations view.mdx (Dutch)

* New translations en.json (Spanish)

* New translations view.mdx (Spanish)

* New translations view.mdx (Dutch)

* New translations en.json (Chinese Simplified)

* New translations view.mdx (Chinese Simplified)

* New translations map-controls.mdx (Chinese Simplified)

* New translations files-and-stats.mdx (Serbian (Latin))

* New translations edit.mdx (Serbian (Latin))

* New translations elevation.mdx (Serbian (Latin))

* New translations en.json (Serbian (Latin))

* New translations files-and-stats.mdx (Serbian (Latin))

* New translations edit.mdx (Serbian (Latin))

* New translations file.mdx (Serbian (Latin))

* New translations settings.mdx (Serbian (Latin))

* New translations view.mdx (Serbian (Latin))

* New translations clean.mdx (Serbian (Latin))

* New translations extract.mdx (Serbian (Latin))

* New translations merge.mdx (Serbian (Latin))

* New translations poi.mdx (Serbian (Latin))

* New translations routing.mdx (Serbian (Latin))

* New translations scissors.mdx (Serbian (Latin))

* New translations time.mdx (Serbian (Latin))

* New translations elevation.mdx (Serbian (Latin))

* New translations minify.mdx (Serbian (Latin))

* New translations routing.mdx (Serbian (Latin))

* New translations faq.mdx (Serbian (Latin))

* New translations integration.mdx (Serbian (Latin))

* New translations files-and-stats.mdx (Serbian (Latin))

* New translations integration.mdx (Serbian (Latin))

* New translations map-controls.mdx (Serbian (Latin))

* New translations menu.mdx (Serbian (Latin))

* New translations edit.mdx (Serbian (Latin))

* New translations file.mdx (Serbian (Latin))

* New translations view.mdx (Serbian (Latin))

* New translations toolbar.mdx (Serbian (Latin))

* New translations routing.mdx (Serbian (Latin))

* New translations scissors.mdx (Serbian (Latin))

* New translations gpx.mdx (Serbian (Latin))

* New translations integration.mdx (Serbian (Latin))

* New translations en.json (Russian)

* New translations elevation.mdx (Dutch)

[ci skip]

* New translations map-controls.mdx (Dutch)

[ci skip]

* New translations elevation.mdx (Dutch)

[ci skip]

* New translations files-and-stats.mdx (Russian)

[ci skip]

* New translations view.mdx (Russian)

[ci skip]

* New translations clean.mdx (Norwegian)

[ci skip]

* New translations clean.mdx (Norwegian)

[ci skip]

* New translations en.json (Norwegian)

[ci skip]

* New translations poi.mdx (Norwegian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Dutch)

[ci skip]
2026-05-21 20:20:49 +02:00
vcoppe 08d1f1d5e4 New Crowdin updates (#334)
* New translations en.json (Basque)

* New translations en.json (Dutch)

* New translations en.json (Catalan)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Belarusian)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Thai)

* New translations en.json (Latvian)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Serbian (Latin))

* New translations view.mdx (Romanian)

* New translations view.mdx (French)

* New translations view.mdx (Spanish)

* New translations view.mdx (Belarusian)

* New translations view.mdx (Catalan)

* New translations view.mdx (Czech)

* New translations view.mdx (Danish)

* New translations view.mdx (German)

* New translations view.mdx (Greek)

* New translations view.mdx (Basque)

* New translations view.mdx (Finnish)

* New translations view.mdx (Hebrew)

* New translations view.mdx (Hungarian)

* New translations view.mdx (Italian)

* New translations view.mdx (Korean)

* New translations view.mdx (Lithuanian)

* New translations view.mdx (Dutch)

* New translations view.mdx (Norwegian)

* New translations view.mdx (Polish)

* New translations view.mdx (Portuguese)

* New translations view.mdx (Russian)

* New translations view.mdx (Swedish)

* New translations view.mdx (Turkish)

* New translations view.mdx (Ukrainian)

* New translations view.mdx (Chinese Simplified)

* New translations view.mdx (Vietnamese)

* New translations view.mdx (Portuguese, Brazilian)

* New translations view.mdx (Indonesian)

* New translations view.mdx (Thai)

* New translations view.mdx (Latvian)

* New translations view.mdx (Chinese Traditional, Hong Kong)

* New translations view.mdx (Serbian (Latin))

* New translations en.json (French)
2026-04-19 19:03:58 +02:00
vcoppe f3bf4f0096 add minimize icon 2026-04-19 18:29:38 +02:00
vcoppe b4094d0a5b slightly resize routing controls 2026-04-19 18:20:01 +02:00
JCarrasco 36122b4ac5 Add fullscreen view mode (#324)
Closes #301
2026-04-19 18:12:02 +02:00
vcoppe dd9aba3adb fine tuning 2026-04-19 16:49:32 +02:00
vcoppe bd40fbae74 fix routing controls on mobile 2026-04-19 16:34:06 +02:00
vcoppe 690cbc49cc avoid querying features when dragging 2026-04-19 16:31:50 +02:00
vcoppe 36b16ddeef catch errors when fetching styles and add fallback one 2026-04-19 14:47:19 +02:00
vcoppe 16b8988fa7 fix layer filtering, must allow unknown intermediary keys 2026-04-17 22:10:30 +02:00
Pablo Ovelleiro Corral 40f97b7c35 Fix: overlays bikerouterGravel, cyclOSMlite, mapterhornHillshade, openRailwayMap cannot be toggled in Layer settings (#329) 2026-04-17 20:07:51 +02:00
vcoppe 54b3113480 New Crowdin updates (#326)
* New translations en.json (Spanish)

* New translations merge.mdx (Spanish)

* New translations elevation.mdx (Spanish)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Spanish)

* New translations integration.mdx (Spanish)

* New translations merge.mdx (Spanish)

* New translations integration.mdx (Spanish)

* New translations map-controls.mdx (Spanish)
2026-04-17 20:00:51 +02:00
vcoppe 7e9140492a fix max zoom 2026-04-09 21:10:45 +02:00
vcoppe 79c0aed54f New Crowdin updates (#323)
* New translations en.json (Basque)

* New translations en.json (Dutch)

* New translations en.json (Catalan)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Belarusian)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Thai)

* New translations en.json (Latvian)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Serbian (Latin))
2026-04-09 20:57:02 +02:00
vcoppe cb5a74de00 add esri satellite 2026-04-09 19:53:53 +02:00
vcoppe 31f25f346a New Crowdin updates (#322)
* New translations en.json (Ukrainian)

* New translations en.json (Ukrainian)

* New translations files-and-stats.mdx (Ukrainian)

* New translations getting-started.mdx (Ukrainian)

* New translations edit.mdx (Ukrainian)

* New translations view.mdx (Ukrainian)

* New translations en.json (Basque)
2026-04-09 19:33:42 +02:00
vcoppe b3a11125a5 adapt temporary anchor layer 2026-04-07 22:19:03 +02:00
vcoppe 71cdc03da5 file specific routing controls layers 2026-04-07 22:15:17 +02:00
vcoppe 694e73a677 reinit shadcn-svelte, fixes #318 2026-04-07 22:01:58 +02:00
vcoppe 5aaacccef9 update components 2026-04-06 18:22:01 +02:00
vcoppe f2bf043900 fix button size 2026-04-06 15:05:47 +02:00
vcoppe ba251fe407 update date picker with language 2026-04-06 15:04:13 +02:00
vcoppe 421aa9dc69 remove now unused package 2026-04-06 14:26:19 +02:00
vcoppe b4a7f1353b New Crowdin updates (#316)
* New translations integration.mdx (Chinese Simplified)

* New translations merge.mdx (Chinese Simplified)

* New translations elevation.mdx (Chinese Simplified)

* New translations en.json (Chinese Simplified)

* New translations file.mdx (Ukrainian)

* New translations en.json (German)

* New translations integration.mdx (German)

* New translations map-controls.mdx (German)

* New translations file.mdx (German)

* New translations merge.mdx (German)

* New translations elevation.mdx (German)
2026-04-04 23:14:44 +02:00
vcoppe 30de3c6db5 update motorcycle profile 2026-04-04 20:10:53 +02:00
vcoppe 8aafa26238 fix for safari 2026-04-03 08:47:40 +02:00
vcoppe 5768391305 set default value for new layers 2026-04-03 08:06:09 +02:00
vcoppe 1d204bacf2 fix threshold 2026-04-02 22:35:36 +02:00
vcoppe 4d1d5d48c0 avoid layout shift 2026-04-02 20:23:27 +02:00
vcoppe 5a6321535e New translations settings.mdx (Greek) (#315) 2026-04-02 18:58:58 +02:00
vcoppe e077de9f48 remove obsolete files 2026-04-02 18:47:59 +02:00
vcoppe 7586f03998 New Crowdin updates (#307)
* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Thai)

* New translations en.json (Latvian)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Serbian (Latin))

* New translations funding.mdx (Dutch)

* New translations en.json (Dutch)

* New translations en.json (Spanish)

* New translations en.json (Basque)

* New translations en.json (Dutch)

* New translations en.json (Catalan)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Belarusian)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Thai)

* New translations en.json (Latvian)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Serbian (Latin))

* New translations files-and-stats.mdx (Romanian)

* New translations files-and-stats.mdx (French)

* New translations files-and-stats.mdx (Spanish)

* New translations files-and-stats.mdx (Belarusian)

* New translations files-and-stats.mdx (Catalan)

* New translations files-and-stats.mdx (Czech)

* New translations files-and-stats.mdx (Danish)

* New translations files-and-stats.mdx (German)

* New translations files-and-stats.mdx (Greek)

* New translations files-and-stats.mdx (Basque)

* New translations files-and-stats.mdx (Finnish)

* New translations files-and-stats.mdx (Hebrew)

* New translations files-and-stats.mdx (Hungarian)

* New translations files-and-stats.mdx (Italian)

* New translations files-and-stats.mdx (Korean)

* New translations files-and-stats.mdx (Lithuanian)

* New translations files-and-stats.mdx (Dutch)

* New translations files-and-stats.mdx (Norwegian)

* New translations files-and-stats.mdx (Polish)

* New translations files-and-stats.mdx (Portuguese)

* New translations files-and-stats.mdx (Russian)

* New translations files-and-stats.mdx (Swedish)

* New translations files-and-stats.mdx (Turkish)

* New translations files-and-stats.mdx (Ukrainian)

* New translations files-and-stats.mdx (Chinese Simplified)

* New translations files-and-stats.mdx (Vietnamese)

* New translations files-and-stats.mdx (Portuguese, Brazilian)

* New translations files-and-stats.mdx (Indonesian)

* New translations files-and-stats.mdx (Thai)

* New translations files-and-stats.mdx (Latvian)

* New translations files-and-stats.mdx (Chinese Traditional, Hong Kong)

* New translations files-and-stats.mdx (Serbian (Latin))

* New translations integration.mdx (Romanian)

* New translations integration.mdx (French)

* New translations integration.mdx (Spanish)

* New translations integration.mdx (Belarusian)

* New translations integration.mdx (Catalan)

* New translations integration.mdx (Czech)

* New translations integration.mdx (Danish)

* New translations integration.mdx (German)

* New translations integration.mdx (Greek)

* New translations integration.mdx (Basque)

* New translations integration.mdx (Finnish)

* New translations integration.mdx (Hebrew)

* New translations integration.mdx (Hungarian)

* New translations integration.mdx (Italian)

* New translations integration.mdx (Korean)

* New translations integration.mdx (Lithuanian)

* New translations integration.mdx (Dutch)

* New translations integration.mdx (Norwegian)

* New translations integration.mdx (Polish)

* New translations integration.mdx (Portuguese)

* New translations integration.mdx (Russian)

* New translations integration.mdx (Swedish)

* New translations integration.mdx (Turkish)

* New translations integration.mdx (Ukrainian)

* New translations integration.mdx (Chinese Simplified)

* New translations integration.mdx (Vietnamese)

* New translations integration.mdx (Portuguese, Brazilian)

* New translations integration.mdx (Indonesian)

* New translations integration.mdx (Thai)

* New translations integration.mdx (Latvian)

* New translations integration.mdx (Chinese Traditional, Hong Kong)

* New translations integration.mdx (Serbian (Latin))

* New translations merge.mdx (Romanian)

* New translations merge.mdx (French)

* New translations merge.mdx (Spanish)

* New translations merge.mdx (Belarusian)

* New translations merge.mdx (Catalan)

* New translations merge.mdx (Czech)

* New translations merge.mdx (Danish)

* New translations merge.mdx (German)

* New translations merge.mdx (Greek)

* New translations merge.mdx (Basque)

* New translations merge.mdx (Finnish)

* New translations merge.mdx (Hebrew)

* New translations merge.mdx (Hungarian)

* New translations merge.mdx (Italian)

* New translations merge.mdx (Korean)

* New translations merge.mdx (Lithuanian)

* New translations merge.mdx (Dutch)

* New translations merge.mdx (Norwegian)

* New translations merge.mdx (Polish)

* New translations merge.mdx (Portuguese)

* New translations merge.mdx (Russian)

* New translations merge.mdx (Swedish)

* New translations merge.mdx (Turkish)

* New translations merge.mdx (Ukrainian)

* New translations merge.mdx (Chinese Simplified)

* New translations merge.mdx (Vietnamese)

* New translations merge.mdx (Portuguese, Brazilian)

* New translations merge.mdx (Indonesian)

* New translations merge.mdx (Thai)

* New translations merge.mdx (Latvian)

* New translations merge.mdx (Chinese Traditional, Hong Kong)

* New translations merge.mdx (Serbian (Latin))

* New translations map-controls.mdx (Romanian)

* New translations map-controls.mdx (French)

* New translations map-controls.mdx (Spanish)

* New translations map-controls.mdx (Belarusian)

* New translations map-controls.mdx (Catalan)

* New translations map-controls.mdx (Czech)

* New translations map-controls.mdx (Danish)

* New translations map-controls.mdx (German)

* New translations map-controls.mdx (Greek)

* New translations map-controls.mdx (Basque)

* New translations map-controls.mdx (Finnish)

* New translations map-controls.mdx (Hebrew)

* New translations map-controls.mdx (Hungarian)

* New translations map-controls.mdx (Italian)

* New translations map-controls.mdx (Korean)

* New translations map-controls.mdx (Lithuanian)

* New translations map-controls.mdx (Dutch)

* New translations map-controls.mdx (Norwegian)

* New translations map-controls.mdx (Polish)

* New translations map-controls.mdx (Portuguese)

* New translations map-controls.mdx (Russian)

* New translations map-controls.mdx (Swedish)

* New translations map-controls.mdx (Turkish)

* New translations map-controls.mdx (Ukrainian)

* New translations map-controls.mdx (Chinese Simplified)

* New translations map-controls.mdx (Vietnamese)

* New translations map-controls.mdx (Portuguese, Brazilian)

* New translations map-controls.mdx (Indonesian)

* New translations map-controls.mdx (Thai)

* New translations map-controls.mdx (Latvian)

* New translations map-controls.mdx (Chinese Traditional, Hong Kong)

* New translations map-controls.mdx (Serbian (Latin))

* New translations en.json (French)

* New translations integration.mdx (French)

* New translations map-controls.mdx (French)

* New translations merge.mdx (French)

* New translations elevation.mdx (French)

* New translations en.json (Basque)

* New translations en.json (Dutch)

* New translations en.json (Catalan)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Belarusian)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Thai)

* New translations en.json (Latvian)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Serbian (Latin))

* New translations integration.mdx (Dutch)

* New translations map-controls.mdx (Spanish)

* New translations map-controls.mdx (Dutch)

* New translations merge.mdx (Dutch)

* New translations en.json (Basque)

* New translations en.json (Dutch)

* New translations en.json (Catalan)

* New translations en.json (Romanian)

* New translations en.json (French)

* New translations en.json (Spanish)

* New translations en.json (Belarusian)

* New translations en.json (Czech)

* New translations en.json (Danish)

* New translations en.json (German)

* New translations en.json (Greek)

* New translations en.json (Finnish)

* New translations en.json (Hebrew)

* New translations en.json (Hungarian)

* New translations en.json (Italian)

* New translations en.json (Korean)

* New translations en.json (Lithuanian)

* New translations en.json (Norwegian)

* New translations en.json (Polish)

* New translations en.json (Portuguese)

* New translations en.json (Russian)

* New translations en.json (Swedish)

* New translations en.json (Turkish)

* New translations en.json (Ukrainian)

* New translations en.json (Chinese Simplified)

* New translations en.json (Vietnamese)

* New translations en.json (Portuguese, Brazilian)

* New translations en.json (Indonesian)

* New translations en.json (Thai)

* New translations en.json (Latvian)

* New translations en.json (Chinese Traditional, Hong Kong)

* New translations en.json (Serbian (Latin))

* New translations en.json (Dutch)

* New translations en.json (French)

* New translations en.json (Spanish)
2026-04-02 18:43:41 +02:00
vcoppe af8c22dcda redirect to app from image 2026-04-02 18:41:49 +02:00
vcoppe 3dce5dc617 improve filtering to always show layers in the same order 2026-04-01 09:01:01 +02:00
vcoppe 84b90e1026 remove unused import 2026-04-01 08:36:33 +02:00
vcoppe d507586eed fix small shift 2026-04-01 08:33:17 +02:00
vcoppe 57afaedf83 rephrasing 2026-03-29 23:22:36 +02:00
vcoppe 48063b9066 allow line breaks in buttons 2026-03-29 23:06:59 +02:00
vcoppe 452d356599 rephrase 2026-03-29 22:56:48 +02:00
vcoppe 25eda8041e slight rephrasing 2026-03-29 22:38:31 +02:00
vcoppe ae4d5356eb fix color 2026-03-29 22:31:57 +02:00
vcoppe 3343bb906e format for crowdin 2026-03-29 20:34:52 +02:00
vcoppe 7b17900160 simplify styling 2026-03-29 20:21:52 +02:00
vcoppe d5f1fe1c7b finish homepage 2026-03-29 20:04:38 +02:00
vcoppe 553f73f992 change break point for centering 2026-03-29 15:14:21 +02:00
vcoppe c8cedf2e2c simplify illustration 2026-03-29 14:06:59 +02:00
vcoppe a751817847 max size for docs 2026-03-28 19:41:44 +01:00
vcoppe d1ef12db8d work in progress 2026-03-28 19:31:52 +01:00
vcoppe 43d73edf29 small fix 2026-03-28 17:12:43 +01:00
vcoppe 5a0b8c376c small ui changes 2026-03-28 13:03:25 +01:00
vcoppe 9743fd460e update embedding instructions 2026-03-28 12:09:31 +01:00
vcoppe f70f92a176 change note type 2026-03-28 11:42:11 +01:00
vcoppe 1a4175446c add missing instructions 2026-03-28 11:40:27 +01:00
vcoppe ed6dfab4c1 fix embedding spacing 2026-03-28 11:32:25 +01:00
vcoppe 6a6e1105c0 update images 2026-03-28 11:32:05 +01:00
vcoppe 1677fe254b move theme button and search bar 2026-03-28 08:41:30 +01:00
vcoppe 02efe708c2 update maplibre 2026-03-27 21:47:17 +01:00
vcoppe 7dc834f506 small ui fixes 2026-03-27 21:32:33 +01:00
vcoppe 57c4958ff2 refresh routing controls on style load 2026-03-27 21:23:51 +01:00
vcoppe 03e59a8cce change default basemap 2026-03-27 19:43:01 +01:00
vcoppe f3d18f09a0 refresh markers on style load 2026-03-27 19:21:25 +01:00
vcoppe c1dbd984e6 fix validator 2026-03-27 19:07:48 +01:00
vcoppe e4f227221d small detail 2026-03-27 18:49:32 +01:00
vcoppe 34139974aa change 3D shortcut 2026-03-27 18:49:20 +01:00
vcoppe 408b2422e6 add missing value 2026-03-27 18:35:25 +01:00
vcoppe b59cb9e200 Merge branch 'maplibre' into dev 2026-03-27 18:31:23 +01:00
vcoppe bd5cb65d0f switch ko-fi to open collective 2026-03-25 21:53:10 +01:00
vcoppe 4cfe487af0 fix typo 2026-03-18 18:35:33 +01:00
vcoppe 4da2e39e32 Merge branch 'graphhopper' into dev
migrate to graphhopper
2026-03-18 18:28:05 +01:00
vcoppe 5ff11a32c9 update readme 2026-03-15 17:00:03 +01:00
vcoppe 01a7ec916e remove console log 2026-03-07 15:59:08 +01:00
vcoppe dd94a7d613 catch graphhopper exceptions 2026-03-07 15:57:58 +01:00
vcoppe 089b88c62d update graphhopper url 2026-03-07 15:30:22 +01:00
vcoppe c9ca75e2e8 small ui improvements 2026-02-17 22:24:14 +01:00
vcoppe 091f6a3ed0 adapt routing control size to canvas width 2026-02-17 21:12:04 +01:00
vcoppe d6c9fb1025 split routing controls in zoom-specific layers to improve performance 2026-02-14 15:05:23 +01:00
vcoppe 88abd72a41 layer instead of markers for routing controls 2026-02-14 14:35:35 +01:00
vcoppe 1137e851ce remove company support section until clarified 2026-02-11 18:31:08 +01:00
vcoppe b8c1500aad fix layer filtering in event manager 2026-02-02 21:50:01 +01:00
vcoppe bfd0d90abc validate settings 2026-02-01 18:45:40 +01:00
vcoppe dba01e1826 finish renaming 2026-02-01 18:06:16 +01:00
vcoppe 2189c76edd renaming 2026-02-01 17:46:24 +01:00
vcoppe 6f8c9d66db use map layers for start/end/hover markers 2026-02-01 17:18:17 +01:00
vcoppe 9408ce10c7 check that map contains the layer 2026-02-01 16:26:17 +01:00
vcoppe 9895c3c304 further improve event listener performance 2026-02-01 15:57:18 +01:00
vcoppe 0ab3b77db8 centralized map layer event listener for better performance 2026-01-31 12:57:08 +01:00
vcoppe d13e7e7a0a update package-lock 2026-01-30 23:13:02 +01:00
vcoppe 9c6e03f4a8 improve layer stacking 2026-01-30 21:30:37 +01:00
vcoppe 2a4dfe010e improve color management 2026-01-30 21:17:59 +01:00
vcoppe f42a916c25 remove unused parameter 2026-01-30 21:17:11 +01:00
vcoppe 772b810fa8 simplify initialization 2026-01-30 21:16:56 +01:00
vcoppe 4d4d10d5c2 small UI tweaks 2026-01-30 21:16:32 +01:00
vcoppe 0e4c7dbe64 New translations en.json (Chinese Simplified) (#306) 2026-01-30 21:02:21 +01:00
vcoppe a01ca79a82 finer-grained road access 2026-01-18 15:23:39 +01:00
vcoppe c91baf7c83 switch gravel to graphhopper 2026-01-17 11:58:47 +01:00
vcoppe 5062de8ddf Merge branch 'dev' into graphhopper 2026-01-17 11:42:30 +01:00
vcoppe 9ca46b9d35 small fix 2025-12-24 17:21:26 +01:00
vcoppe 7c2e24bbc4 draft support for graphhopper 2025-12-23 16:49:47 +01:00
592 changed files with 9756 additions and 8472 deletions
+1 -1
View File
@@ -1 +1 @@
ko_fi: gpxstudio
open_collective: gpxstudio
+3 -3
View File
@@ -5,7 +5,7 @@
[**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files.
![gpx.studio screenshot](website/src/lib/assets/img/docs/getting-started/interface.png)
![gpx.studio screenshot](website/src/lib/assets/img/docs/getting-started/interface.webp)
This repository contains the source code of the website.
@@ -69,8 +69,8 @@ This project has been made possible thanks to the following open source projects
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
- Mapping:
- [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive maps
- [brouter](https://github.com/abrensch/brouter) — routing engine
- [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive map rendering
- [GraphHopper](https://github.com/graphhopper/graphhopper) — routing engine
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by most of the map layers, and by the routing engine
- Search:
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
+6 -5
View File
@@ -1398,10 +1398,7 @@ export class TrackPoint {
: undefined;
}
setExtensions(extensions: Record<string, string>) {
if (Object.keys(extensions).length === 0) {
return;
}
setExtension(key: string, value: string) {
if (!this.extensions) {
this.extensions = {};
}
@@ -1411,8 +1408,12 @@ export class TrackPoint {
if (!this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']) {
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] = {};
}
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value;
}
setExtensions(extensions: Record<string, string>) {
Object.entries(extensions).forEach(([key, value]) => {
this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'][key] = value;
this.setExtension(key, value);
});
}
+18 -15
View File
@@ -1,17 +1,20 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"css": "src/app.css",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry",
"style": "nova",
"iconLibrary": "lucide",
"menuColor": "default",
"menuAccent": "subtle"
}
+3676 -3861
View File
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -14,7 +14,9 @@
"format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore"
},
"devDependencies": {
"@lucide/svelte": "^0.544.0",
"@fontsource-variable/inter": "^5.2.8",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.7.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.6.0",
"@sveltejs/kit": "^2.21.2",
@@ -30,7 +32,8 @@
"@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^8.33.1",
"@typescript-eslint/parser": "^8.33.1",
"bits-ui": "^2.14.4",
"bits-ui": "^2.17.2",
"clsx": "^2.1.1",
"eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.9.1",
@@ -43,39 +46,37 @@
"postcss": "^8.4.47",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.4.0",
"shadcn-svelte": "^1.2.7",
"svelte": "^5.33.18",
"svelte-check": "^4.0.0",
"svelte-dnd-action": "^0.9.65",
"svelte-sonner": "^1.0.5",
"tailwind-variants": "^3.1.1",
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.8",
"tslib": "^2.8.1",
"tsx": "^4.19.1",
"tw-animate-css": "^1.3.4",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.3",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^6.3.5",
"vite-plugin-node-polyfills": "^0.23.0"
"vite": "^6.3.5"
},
"type": "module",
"dependencies": {
"@docsearch/js": "^3.9.0",
"@internationalized/date": "^3.8.2",
"@mapbox/sphericalmercator": "^2.0.1",
"@mapbox/tilebelt": "^2.0.2",
"@maplibre/maplibre-gl-geocoder": "^1.9.4",
"chart.js": "^4.5.1",
"chartjs-plugin-zoom": "^2.2.0",
"clsx": "^2.1.1",
"dexie": "^4.0.11",
"file-saver": "^2.0.5",
"gpx": "file:../gpx",
"immer": "^10.1.1",
"jszip": "^3.10.1",
"mapillary-js": "^4.1.2",
"maplibre-gl": "^5.16.0",
"maplibre-gl": "^5.21.1",
"sanitize-html": "^2.17.0",
"sortablejs": "^1.15.6",
"tailwind-merge": "^3.3.0"
"sortablejs": "^1.15.6"
}
}
+91 -53
View File
@@ -1,76 +1,93 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import "shadcn-svelte/tailwind.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *));
:root {
--background: hsl(0 0% 100%) /* <- Wrap in HSL */;
--foreground: hsl(240 10% 3.9%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 72.2% 50.6%);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 10% 3.9%);
--sidebar: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--ring: oklch(0.708 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--support: rgb(220 15 130);
--link: rgb(0 110 180);
--selection: hsl(240 4.8% 93%);
--radius: 0.5rem;
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
}
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 4.9% 83.9%);
--sidebar: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--support: rgb(255 110 190);
--link: rgb(80 190 255);
--selection: hsl(240 3.7% 22%);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
}
@theme inline {
@@ -113,14 +130,35 @@
--color-link: var(--link);
--breakpoint-xs: 540px;
--font-sans: 'Inter Variable', sans-serif;
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@layer base {
* {
@apply border-border;
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 KiB

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 KiB

After

Width:  |  Height:  |  Size: 710 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

+34 -16
View File
@@ -31,9 +31,30 @@ import bikerouterGravel from './custom/bikerouter-gravel.json';
export const maptilerKeyPlaceHolder = 'MAPTILER_KEY';
export const basemaps: { [key: string]: string | StyleSpecification } = {
maptilerTopo: `https://api.maptiler.com/maps/topo-v4/style.json?key=${maptilerKeyPlaceHolder}`,
maptilerOutdoors: `https://api.maptiler.com/maps/outdoor-v4/style.json?key=${maptilerKeyPlaceHolder}`,
maptilerSatellite: `https://api.maptiler.com/maps/hybrid-v4/style.json?key=${maptilerKeyPlaceHolder}`,
topo: 'https://raw.githubusercontent.com/gpxstudio/styles/refs/heads/main/topo.json',
satellite: 'https://raw.githubusercontent.com/gpxstudio/styles/refs/heads/main/satellite.json',
esriSatellite: {
version: 8,
sources: {
esriSatellite: {
type: 'raster',
tiles: [
'https://services.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/WMTS/tile/1.0.0/World_Imagery/default/default028mm/{z}/{y}/{x}.jpg',
],
tileSize: 256,
maxzoom: 19,
attribution:
'© <a href="https://www.esri.com/" target="_blank">Esri</a>, Vantor, Earthstar Geographics, and the GIS User Community',
},
},
layers: [
{
id: 'esriSatellite',
type: 'raster',
source: 'esriSatellite',
},
],
},
openStreetMap: {
version: 8,
sources: {
@@ -776,9 +797,9 @@ export type LayerTreeType = { [key: string]: LayerTreeType | boolean };
export const basemapTree: LayerTreeType = {
basemaps: {
world: {
maptilerTopo: true,
maptilerOutdoors: true,
maptilerSatellite: true,
topo: true,
satellite: true,
esriSatellite: true,
openStreetMap: true,
openTopoMap: true,
openHikingMap: true,
@@ -911,7 +932,7 @@ export const overpassTree: LayerTreeType = {
};
// Default basemap used
export const defaultBasemap = 'maptilerTopo';
export const defaultBasemap = 'topo';
// Default overlays used (none)
export const defaultOverlays: LayerTreeType = {
@@ -1000,9 +1021,9 @@ export const defaultOverpassQueries: LayerTreeType = {
export const defaultBasemapTree: LayerTreeType = {
basemaps: {
world: {
maptilerTopo: true,
maptilerOutdoors: true,
maptilerSatellite: true,
topo: true,
satellite: true,
esriSatellite: false,
openStreetMap: true,
openTopoMap: true,
openHikingMap: true,
@@ -1460,14 +1481,11 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
};
export const terrainSources: { [key: string]: RasterDEMSourceSpecification } = {
'maptiler-dem': {
type: 'raster-dem',
url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${maptilerKeyPlaceHolder}`,
},
mapterhorn: {
type: 'raster-dem',
url: 'https://tiles.mapterhorn.com/tilejson.json',
url: 'https://tiles.gpx.studio/mapterhorn.json',
encoding: 'terrarium',
},
};
export const defaultTerrainSource = 'maptiler-dem';
export const defaultTerrainSource = 'mapterhorn';
@@ -64,3 +64,9 @@
</svelte:head>
<div id="docsearch" class={props.class ?? ''}></div>
<style>
#docsearch :global(button) {
margin-left: 0px;
}
</style>
@@ -26,7 +26,7 @@
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button {...props} {variant} class={className} {onclick}>
<Button {...props} {variant} class="bg-inherit {className}" {onclick}>
{@render children()}
</Button>
{/snippet}
+99 -97
View File
@@ -1,116 +1,118 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
import Logo from '$lib/components/Logo.svelte';
import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils';
</script>
<footer class="w-full">
<div class="mx-6 border-t">
<div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="grow flex flex-col items-start">
<Logo class="h-8" width="153" />
<footer class="w-full px-12 py-10 border-t flex flex-col items-center">
<div class="w-full max-w-5xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="grow flex flex-col items-start">
<Logo class="h-8" width="153" />
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
target="_blank"
>
MIT © 2026 gpx.studio
</Button>
<div class="mt-3 flex flex-row gap-1.5">
<LanguageSelect />
<ModeSwitch />
</div>
</div>
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="flex flex-col items-start gap-1">
<span class="font-semibold">{i18n._('homepage.website')}</span>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
href={getURLForLanguage(i18n.lang, '/')}
>
<House size="16" />
{i18n._('homepage.home')}
</Button>
<Button
data-sveltekit-reload
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/app')}
>
<Map size="16" />
{i18n._('homepage.app')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/help')}
>
<BookOpenText size="16" />
{i18n._('menu.help')}
</Button>
</div>
<div class="flex flex-col items-start gap-1" id="contact">
<span class="font-semibold">{i18n._('homepage.contact')}</span>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://www.reddit.com/r/gpxstudio/"
target="_blank"
>
MIT © 2026 gpx.studio
<Logo company="reddit" class="h-4 fill-muted-foreground" />
{i18n._('homepage.reddit')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://facebook.com/gpx.studio"
target="_blank"
>
<Logo company="facebook" class="h-4 fill-muted-foreground" />
{i18n._('homepage.facebook')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="mailto:hello@gpx.studio"
target="_blank"
>
<AtSign size="16" />
{i18n._('homepage.email')}
</Button>
<LanguageSelect class="w-40 mt-3" />
</div>
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
<div class="flex flex-col items-start gap-1">
<span class="font-semibold">{i18n._('homepage.website')}</span>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/')}
>
<House size="16" />
{i18n._('homepage.home')}
</Button>
<Button
data-sveltekit-reload
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/app')}
>
<Map size="16" />
{i18n._('homepage.app')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href={getURLForLanguage(i18n.lang, '/help')}
>
<BookOpenText size="16" />
{i18n._('menu.help')}
</Button>
</div>
<div class="flex flex-col items-start gap-1" id="contact">
<span class="font-semibold">{i18n._('homepage.contact')}</span>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://www.reddit.com/r/gpxstudio/"
target="_blank"
>
<Logo company="reddit" class="h-4 fill-muted-foreground" />
{i18n._('homepage.reddit')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://facebook.com/gpx.studio"
target="_blank"
>
<Logo company="facebook" class="h-4 fill-muted-foreground" />
{i18n._('homepage.facebook')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="mailto:hello@gpx.studio"
target="_blank"
>
<AtSign size="16" />
{i18n._('homepage.email')}
</Button>
</div>
<div class="flex flex-col items-start gap-1">
<span class="font-semibold">{i18n._('homepage.contribute')}</span>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://ko-fi.com/gpxstudio"
target="_blank"
>
<Heart size="16" />
{i18n._('menu.donate')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://crowdin.com/project/gpxstudio"
target="_blank"
>
<Logo company="crowdin" class="h-4 fill-muted-foreground" />
{i18n._('homepage.crowdin')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio"
target="_blank"
>
<Logo company="github" class="h-4 fill-muted-foreground" />
{i18n._('homepage.github')}
</Button>
</div>
<div class="flex flex-col items-start gap-1">
<span class="font-semibold">{i18n._('homepage.contribute')}</span>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://opencollective.com/gpxstudio"
target="_blank"
>
<Heart size="16" />
{i18n._('menu.donate')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://crowdin.com/project/gpxstudio"
target="_blank"
>
<Logo company="crowdin" class="h-4 fill-muted-foreground" />
{i18n._('homepage.crowdin')}
</Button>
<Button
variant="link"
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
href="https://github.com/gpxstudio/gpx.studio"
target="_blank"
>
<Logo company="github" class="h-4 fill-muted-foreground" />
{i18n._('homepage.github')}
</Button>
</div>
</div>
</div>
+52 -49
View File
@@ -12,16 +12,17 @@
const { velocityUnits } = settings;
let panelHeight: number = $state(0);
let panelWidth: number = $state(0);
let {
gpxStatistics,
slicedGPXStatistics,
orientation,
panelSize,
}: {
gpxStatistics: Readable<GPXStatisticsGroup>;
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
orientation: 'horizontal' | 'vertical';
panelSize: number;
} = $props();
let statistics = $derived(
@@ -31,59 +32,61 @@
<Card.Root
class="h-full {orientation === 'vertical'
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
: 'w-full'} border-none shadow-none p-0"
? 'min-w-40 sm:min-w-44'
: 'w-full h-fit my-1'} ring-0 p-0 text-sm sm:text-base bg-transparent"
>
<Card.Content
class="h-full flex {orientation === 'vertical'
? 'flex-col justify-center'
: 'flex-row w-full justify-between'} gap-4 p-0"
>
<Tooltip label={i18n._('quantities.distance')}>
<span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" />
<WithUnits value={statistics.distance.total} type="distance" />
</span>
</Tooltip>
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.elevation.gain} type="elevation" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.elevation.loss} type="elevation" />
</span>
</Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
label="{$velocityUnits === 'speed'
? i18n._('quantities.speed')
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<Card.Content class="h-full p-0">
<div
bind:clientHeight={panelHeight}
bind:clientWidth={panelWidth}
class="flex {orientation === 'vertical'
? 'flex-col h-full justify-center'
: 'flex-row w-full justify-evenly'} gap-4"
>
<Tooltip label={i18n._('quantities.distance')}>
<span class="flex flex-row items-center">
<Zap size="16" class="mr-1" />
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
<span class="mx-1">/</span>
<WithUnits value={statistics.speed.total} type="speed" />
<Ruler size="16" class="mr-1" />
<WithUnits value={statistics.distance.total} type="distance" />
</span>
</Tooltip>
{/if}
{#if panelSize > 160 || orientation === 'horizontal'}
<Tooltip
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.time.moving} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.time.total} type="time" />
<MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.elevation.gain} type="elevation" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.elevation.loss} type="elevation" />
</span>
</Tooltip>
{/if}
{#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)}
<Tooltip
label="{$velocityUnits === 'speed'
? i18n._('quantities.speed')
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center">
<Zap size="16" class="mr-1" />
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
<span class="mx-1">/</span>
<WithUnits value={statistics.speed.total} type="speed" />
</span>
</Tooltip>
{/if}
{#if panelHeight > 150 || (orientation === 'horizontal' && panelWidth > 620)}
<Tooltip
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
'quantities.total'
)})"
>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.time.moving} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.time.total} type="time" />
</span>
</Tooltip>
{/if}
</div>
</Card.Content>
</Card.Root>
+2 -2
View File
@@ -14,12 +14,12 @@
} = $props();
</script>
<div class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {className}">
<div class="text-[13px] bg-secondary rounded border flex flex-row items-center p-2 {className}">
<CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" />
<div>
{@render children()}
{#if link}
<a href={link} target="_blank" class="text-sm text-link hover:underline">
<a href={link} target="_blank" class="text-[13px] text-link hover:underline">
{i18n._('menu.more')}
</a>
{/if}
@@ -5,16 +5,10 @@
import { getURLForLanguage } from '$lib/utils';
import { Languages } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
let {
class: className = '',
}: {
class?: string;
} = $props();
</script>
<Select.Root type="single" value={i18n.lang}>
<Select.Trigger class="min-w-[180px] {className}" aria-label={i18n._('menu.language')}>
<Select.Trigger class="w-[180px] px-2" aria-label={i18n._('menu.language')}>
<Languages size="16" />
<span class="mr-auto">
{languages[i18n.lang]}
+1 -7
View File
@@ -8,7 +8,7 @@
...others
}: {
iconOnly?: boolean;
company?: 'gpx.studio' | 'maptiler' | 'github' | 'crowdin' | 'facebook' | 'reddit';
company?: 'gpx.studio' | 'github' | 'crowdin' | 'facebook' | 'reddit';
[key: string]: any;
} = $props();
</script>
@@ -19,12 +19,6 @@
alt="Logo of gpx.studio."
{...others}
/>
{:else if company === 'maptiler'}
<img
src="{base}/maptiler-logo{mode.current === 'dark' ? '-dark' : ''}.svg"
alt="Logo of Maptiler."
{...others}
/>
{:else if company === 'github'}
<svg
role="img"
+42 -13
View File
@@ -43,6 +43,8 @@
BookOpenText,
ChartArea,
Maximize,
Maximize2,
Minimize2,
} from '@lucide/svelte';
import { map } from '$lib/components/map/map';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
@@ -70,7 +72,7 @@
import { copied, selection } from '$lib/logic/selection';
import { allHidden } from '$lib/logic/hidden';
import { boundsManager } from '$lib/logic/bounds';
import { tick } from 'svelte';
import { tick, onMount } from 'svelte';
import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
const {
@@ -105,6 +107,23 @@
}
let layerSettingsOpen = $state(false);
let fullscreen = $state(false);
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
}
onMount(() => {
const handler = () => {
fullscreen = document.fullscreenElement !== null;
};
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
});
</script>
<div class="absolute md:top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none">
@@ -375,8 +394,18 @@
<Menubar.Item inset onclick={() => map.toggle3D()}>
<Box size="16" />
{i18n._('menu.toggle_3d')}
<Shortcut key="{i18n._('menu.ctrl')} {i18n._('menu.drag')}" />
<Shortcut key={i18n._('menu.right_click_drag')} />
</Menubar.Item>
<Menubar.Separator />
<Menubar.CheckboxItem checked={fullscreen} onCheckedChange={toggleFullscreen}>
{#if fullscreen}
<Minimize2 size="16" />
{:else}
<Maximize2 size="16" />
{/if}
{i18n._('menu.fullscreen')}
<Shortcut key="F11" />
</Menubar.CheckboxItem>
</Menubar.Content>
</Menubar.Menu>
<Menubar.Menu>
@@ -389,7 +418,7 @@
<Menubar.Content class="border-none">
<Menubar.Sub>
<Menubar.SubTrigger>
<Ruler size="16" class="mr-2" />{i18n._('menu.distance_units')}
<Ruler size="16" />{i18n._('menu.distance_units')}
</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.RadioGroup bind:value={$distanceUnits}>
@@ -407,7 +436,7 @@
</Menubar.Sub>
<Menubar.Sub>
<Menubar.SubTrigger>
<Zap size="16" class="mr-2" />{i18n._('menu.velocity_units')}
<Zap size="16" />{i18n._('menu.velocity_units')}
</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.RadioGroup bind:value={$velocityUnits}>
@@ -422,7 +451,7 @@
</Menubar.Sub>
<Menubar.Sub>
<Menubar.SubTrigger>
<Thermometer size="16" class="mr-2" />{i18n._('menu.temperature_units')}
<Thermometer size="16" />{i18n._('menu.temperature_units')}
</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.RadioGroup bind:value={$temperatureUnits}>
@@ -438,7 +467,7 @@
<Menubar.Separator />
<Menubar.Sub>
<Menubar.SubTrigger>
<Languages size="16" class="mr-2" />
<Languages size="16" />
{i18n._('menu.language')}
</Menubar.SubTrigger>
<Menubar.SubContent>
@@ -454,9 +483,9 @@
<Menubar.Sub>
<Menubar.SubTrigger>
{#if mode.current === 'light' || !mode.current}
<Sun size="16" class="mr-2" />
<Sun size="16" />
{:else}
<Moon size="16" class="mr-2" />
<Moon size="16" />
{/if}
{i18n._('menu.mode')}
</Menubar.SubTrigger>
@@ -479,7 +508,7 @@
<Menubar.Separator />
<Menubar.Sub>
<Menubar.SubTrigger>
<PersonStanding size="16" class="mr-2" />
<PersonStanding size="16" />
{i18n._('menu.street_view_source')}
</Menubar.SubTrigger>
<Menubar.SubContent>
@@ -500,12 +529,12 @@
</Menubar.Content>
</Menubar.Menu>
</Menubar.Root>
<div class="h-fit flex flex-row items-center ml-1 gap-1">
<div class="h-fit flex flex-row items-center">
<Button
variant="ghost"
href="./help"
target="_blank"
class="cursor-default h-fit rounded-sm px-3 py-0.5"
class="cursor-default h-fit rounded-md px-3 py-0.5"
aria-label={i18n._('menu.help')}
>
<BookOpenText size="18" class="md:hidden" />
@@ -515,9 +544,9 @@
</Button>
<Button
variant="ghost"
href="https://ko-fi.com/gpxstudio"
href="https://opencollective.com/gpxstudio"
target="_blank"
class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5"
class="cursor-default h-fit rounded-md font-bold text-support hover:text-support px-3 py-0.5"
aria-label={i18n._('menu.donate')}
>
<HeartHandshake size="18" class="md:hidden" />
+1 -1
View File
@@ -12,7 +12,7 @@
</script>
<Button
variant="ghost"
variant="outline"
size="icon"
class={className}
onclick={() => {
+9 -10
View File
@@ -1,22 +1,23 @@
<script lang="ts">
import Logo from '$lib/components/Logo.svelte';
import { Button } from '$lib/components/ui/button';
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
import { BookOpenText, House, Map } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { getURLForLanguage } from '$lib/utils';
</script>
<nav class="w-full sticky top-0 bg-background z-50">
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
<a href={getURLForLanguage(i18n.lang, '/')} class="shrink-0 translate-y-0.5">
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
<Logo class="h-8 hidden sm:block" width="153" />
<nav class="sticky top-0 w-full px-12 py-2 bg-background z-50 flex flex-col items-center border-b">
<div class="w-full max-w-5xl flex flex-row items-center gap-4 sm:gap-8">
<a
href={getURLForLanguage(i18n.lang, '/')}
class="shrink-0 translate-y-0.25 justify-self-start"
>
<Logo class="h-8 xs:hidden" iconOnly={true} width="26" />
<Logo class="h-8 hidden xs:block" width="153" />
</a>
<Button
variant="link"
class="text-base px-0 has-[>svg]:px-0"
class="text-base px-0 has-[>svg]:px-0 ml-auto"
href={getURLForLanguage(i18n.lang, '/')}
>
<House size="18" />
@@ -39,7 +40,5 @@
<BookOpenText size="18" />
{i18n._('menu.help')}
</Button>
<AlgoliaDocSearch class="ml-auto" />
<ModeSwitch class="hidden xs:inline-flex" />
</div>
</nav>
@@ -35,7 +35,7 @@
<Button
variant="ghost"
size="icon"
class="w-full flex flex-row gap-1 {side === 'right'
class="w-full flex flex-row gap-1 border-none {side === 'right'
? 'justify-between'
: 'justify-start pl-1'} h-fit {nohover
? 'hover:bg-background'
@@ -62,7 +62,7 @@
<Button
variant="ghost"
size="icon"
class="w-full flex flex-row gap-1 {side === 'right'
class="w-full flex flex-row gap-1 border-none {side === 'right'
? 'justify-between'
: 'justify-start pl-1'} h-fit {nohover ? 'hover:bg-background' : ''}"
>
@@ -19,7 +19,7 @@
@apply text-foreground;
@apply text-3xl;
@apply font-semibold;
@apply mb-3 pt-6;
@apply mb-3;
}
:global(.markdown h2) {
@@ -12,7 +12,7 @@
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
{#if src === 'getting-started/interface'}
<enhanced:img
src="/src/lib/assets/img/docs/getting-started/interface.png"
src="/src/lib/assets/img/docs/getting-started/interface.webp"
{alt}
class="w-full max-w-3xl"
/>
@@ -20,13 +20,13 @@
<enhanced:img
src="/src/lib/assets/img/docs/tools/routing.png"
{alt}
class="w-full max-w-3xl"
class="w-full max-w-lg"
/>
{:else if src === 'tools/split'}
<enhanced:img
src="/src/lib/assets/img/docs/tools/split.png"
{alt}
class="w-full max-w-3xl"
class="w-full max-w-lg"
/>
{/if}
</div>
@@ -1,10 +1,10 @@
<script lang="ts">
import maptilerTopoMap from '$lib/assets/img/home/maptiler-topo.png?enhanced';
import waymarkedMap from '$lib/assets/img/home/waymarked.png?enhanced';
import topoMap from '$lib/assets/img/docs/maps/topo.png?enhanced';
import waymarkedMap from '$lib/assets/img/docs/maps/waymarked.png?enhanced';
</script>
<div class="relative h-80 aspect-square rounded-2xl shadow-xl overflow-clip">
<enhanced:img src={maptilerTopoMap} alt="MapTiler Topo map screenshot." class="absolute" />
<enhanced:img src={topoMap} alt="Topo map screenshot." class="absolute" />
<enhanced:img
src={waymarkedMap}
alt="Waymarked Trails map screenshot."
@@ -18,7 +18,7 @@
Construction,
} from '@lucide/svelte';
import type { Readable, Writable } from 'svelte/store';
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n.svelte';
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
@@ -28,12 +28,14 @@
let {
gpxStatistics,
slicedGPXStatistics,
hoveredPoint,
additionalDatasets,
elevationFill,
showControls = true,
}: {
gpxStatistics: Readable<GPXStatisticsGroup>;
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
hoveredPoint: Writable<Coordinates | null>;
additionalDatasets: Writable<string[]>;
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
showControls?: boolean;
@@ -47,6 +49,7 @@
elevationProfile = new ElevationProfile(
gpxStatistics,
slicedGPXStatistics,
hoveredPoint,
additionalDatasets,
elevationFill,
canvas,
@@ -61,37 +64,35 @@
});
</script>
<div class="h-full grow min-w-0 relative py-2">
<div class="h-full grow min-w-0 min-h-0 relative">
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas>
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas>
{#if showControls}
<div class="absolute bottom-10 right-1.5">
<div class="absolute bottom-9 right-2.5">
<Popover.Root>
<Popover.Trigger>
<ButtonWithTooltip
label={i18n._('chart.settings')}
variant="outline"
side="left"
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 hover:bg-background"
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 bg-background"
>
<ChartNoAxesColumn size="18" />
</ButtonWithTooltip>
</Popover.Trigger>
<Popover.Content
class="w-fit p-0 flex flex-col"
class="w-fit p-0 flex flex-col gap-0 overflow-hidden"
side="top"
align="end"
sideOffset={-32}
>
<ToggleGroup.Root
class="flex flex-col items-start gap-0 p-1 w-full border-none"
class="flex flex-col w-full border-none"
type="single"
size="sm"
bind:value={$elevationFill}
>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="slope"
>
<ToggleGroup.Item value="slope" class="w-full flex flex-row justify-start">
<div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'slope'}
<Circle class="size-1.5 fill-current text-current" />
@@ -101,9 +102,8 @@
{i18n._('quantities.slope')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="surface"
variant="outline"
class="w-full flex flex-row justify-start"
>
<div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'surface'}
@@ -114,9 +114,8 @@
{i18n._('quantities.surface')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="highway"
variant="outline"
class="w-full flex flex-row justify-start"
>
<div class="w-6 flex justify-center items-center">
{#if $elevationFill === 'highway'}
@@ -129,14 +128,12 @@
</ToggleGroup.Root>
<Separator />
<ToggleGroup.Root
class="flex flex-col items-start gap-0 p-1"
class="flex flex-col gap-0"
type="multiple"
size="sm"
bind:value={$additionalDatasets}
>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="speed"
>
<ToggleGroup.Item value="speed" class="w-full flex flex-row justify-start">
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('speed')}
<Check size="14" />
@@ -147,10 +144,7 @@
? i18n._('quantities.speed')
: i18n._('quantities.pace')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="hr"
>
<ToggleGroup.Item value="hr" class="w-full flex flex-row justify-start">
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('hr')}
<Check size="14" />
@@ -159,10 +153,7 @@
<HeartPulse size="15" />
{i18n._('quantities.heartrate')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="cad"
>
<ToggleGroup.Item value="cad" class="w-full flex flex-row justify-start">
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('cad')}
<Check size="14" />
@@ -171,10 +162,7 @@
<Orbit size="15" />
{i18n._('quantities.cadence')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="atemp"
>
<ToggleGroup.Item value="atemp" class="w-full flex flex-row justify-start">
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('atemp')}
<Check size="14" />
@@ -183,10 +171,7 @@
<Thermometer size="15" />
{i18n._('quantities.temperature')}
</ToggleGroup.Item>
<ToggleGroup.Item
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
value="power"
>
<ToggleGroup.Item value="power" class="w-full flex flex-row justify-start">
<div class="w-6 flex justify-center items-center">
{#if $additionalDatasets.includes('power')}
<Check size="14" />
@@ -20,10 +20,8 @@ import Chart, {
type ScriptableLineSegmentContext,
type TooltipItem,
} from 'chart.js/auto';
import maplibregl from 'maplibre-gl';
import { get, type Readable, type Writable } from 'svelte/store';
import { map } from '$lib/components/map/map';
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import { mode } from 'mode-watcher';
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
@@ -42,7 +40,7 @@ interface ElevationProfilePoint {
length: number;
};
extensions: Record<string, any>;
coordinates: [number, number];
coordinates: Coordinates;
index: number;
}
@@ -50,18 +48,19 @@ export class ElevationProfile {
private _chart: Chart | null = null;
private _canvas: HTMLCanvasElement;
private _overlay: HTMLCanvasElement;
private _marker: maplibregl.Marker | null = null;
private _dragging = false;
private _panning = false;
private _gpxStatistics: Readable<GPXStatisticsGroup>;
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
private _hoveredPoint: Writable<Coordinates | null>;
private _additionalDatasets: Readable<string[]>;
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
constructor(
gpxStatistics: Readable<GPXStatisticsGroup>,
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
hoveredPoint: Writable<Coordinates | null>,
additionalDatasets: Readable<string[]>,
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
canvas: HTMLCanvasElement,
@@ -69,17 +68,12 @@ export class ElevationProfile {
) {
this._gpxStatistics = gpxStatistics;
this._slicedGPXStatistics = slicedGPXStatistics;
this._hoveredPoint = hoveredPoint;
this._additionalDatasets = additionalDatasets;
this._elevationFill = elevationFill;
this._canvas = canvas;
this._overlay = overlay;
let element = document.createElement('div');
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
this._marker = new maplibregl.Marker({
element,
});
import('chartjs-plugin-zoom').then((module) => {
Chart.register(module.default);
this.initialize();
@@ -162,14 +156,10 @@ export class ElevationProfile {
label: (context: TooltipItem<'line'>) => {
let point = context.raw as ElevationProfilePoint;
if (context.datasetIndex === 0) {
const map_ = get(map);
if (map_ && this._marker) {
if (this._dragging) {
this._marker.remove();
} else {
this._marker.setLngLat(point.coordinates);
this._marker.addTo(map_);
}
if (this._dragging) {
this._hoveredPoint.set(null);
} else {
this._hoveredPoint.set(point.coordinates);
}
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 1) {
@@ -312,10 +302,7 @@ export class ElevationProfile {
events: ['mouseout'],
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
if (args.event.type === 'mouseout') {
const map_ = get(map);
if (map_ && this._marker) {
this._marker.remove();
}
this._hoveredPoint.set(null);
}
},
},
@@ -637,8 +624,5 @@ export class ElevationProfile {
this._chart.destroy();
this._chart = null;
}
if (this._marker) {
this._marker.remove();
}
}
}
@@ -16,7 +16,7 @@
import { setMode } from 'mode-watcher';
import { settings } from '$lib/logic/settings';
import { fileStateCollection } from '$lib/logic/file-state';
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
import { loadFile } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection';
import { untrack } from 'svelte';
@@ -102,7 +102,7 @@
<div class="grow relative">
<Map
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
accessToken={options.token}
maptilerKey={options.key}
geocoder={false}
geolocate={true}
hash={useHash}
@@ -117,19 +117,19 @@
{/if}
</div>
<div
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 px-2 sm:px-4"
class="{options.elevation.show ? '' : 'h-10'} flex flex-row gap-2 p-2 sm:px-4"
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
>
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
panelSize={options.elevation.height}
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
/>
{#if options.elevation.show}
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
showControls={options.elevation.controls}
@@ -32,7 +32,7 @@
let options = $state(
getMergedEmbeddingOptions(
{
token: 'YOUR_MAPTILER_KEY',
key: 'YOUR_MAPTILER_KEY',
theme: mode.current,
},
defaultEmbeddingOptions
@@ -46,7 +46,7 @@
let iframeOptions = $derived(
getMergedEmbeddingOptions(
{
token:
key:
options.key.length === 0 || options.key === 'YOUR_MAPTILER_KEY'
? PUBLIC_MAPTILER_KEY
: options.key,
@@ -29,7 +29,7 @@ export const defaultEmbeddingOptions = {
key: '',
files: [],
ids: [],
basemap: 'maptilerTopo',
basemap: 'topo',
elevation: {
show: true,
height: 170,
@@ -90,6 +90,9 @@ export function getCleanedEmbeddingOptions(
delete cleanedOptions[key];
}
}
if (cleanedOptions['key'] && cleanedOptions['key'] === PUBLIC_MAPTILER_KEY) {
delete cleanedOptions['key'];
}
return cleanedOptions;
}
@@ -122,9 +125,7 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
}
if (options.has('source')) {
let basemap = options.get('source')!;
if (basemap === 'satellite') {
newOptions.basemap = 'maptilerSatellite';
} else if (basemap === 'otm') {
if (basemap === 'otm') {
newOptions.basemap = 'openTopoMap';
} else if (basemap === 'ohm') {
newOptions.basemap = 'openHikingMap';
@@ -100,7 +100,11 @@
</span>
</div>
<div class="w-full flex flex-row flex-wrap gap-2">
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
<Button
class="bg-support grow"
href="https://opencollective.com/gpxstudio"
target="_blank"
>
{i18n._('menu.support_button')}
<span>🙏</span>
</Button>
@@ -114,10 +114,10 @@
<ContextMenu.Trigger class="grow truncate">
<Button
variant="ghost"
class="relative w-full p-0 overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
class="relative w-full p-0 overflow-hidden border-none focus-visible:ring-0 focus-visible:ring-offset-0 flex flex-row {orientation ===
'vertical'
? 'h-fit'
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
? 'h-7'
: 'h-9 px-1.5'} pointer-events-auto"
>
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
<MetadataDialog bind:open={openEditMetadata} {node} {item} />
@@ -126,7 +126,7 @@
{#if item.level === ListLevel.FILE || item.level === ListLevel.TRACK}
<div
class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-1 w-1'
? 'top-0 bottom-0 right-0 w-1'
: 'top-0 h-1 left-0 right-0'}"
style="background:linear-gradient(to {orientation === 'vertical'
? 'bottom'
@@ -139,7 +139,7 @@
></div>
{/if}
<span
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
class="grow text-left truncate ml-1 flex flex-row items-center {hidden
? 'text-muted-foreground'
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
? 'text-muted-foreground'
@@ -5,6 +5,16 @@
map.onLoad((map_) => {
map_.on('contextmenu', (e) => {
if (
map_.queryRenderedFeatures(e.point, {
layers: map_
.getLayersOrder()
.filter((layerId) => layerId.startsWith('routing-controls')),
}).length
) {
// Clicked on routing control, ignoring
return;
}
trackpointPopup?.setItem({
item: new TrackPoint({
attributes: {
+10 -1
View File
@@ -129,12 +129,21 @@
@apply relative;
@apply top-0;
@apply left-0;
@apply my-2;
@apply w-[29px];
}
div :global(.maplibregl-ctrl-geocoder--icon-loading) {
@apply -mt-1;
@apply mb-0;
}
div :global(.maplibregl-ctrl-geocoder--icon-close) {
@apply my-0;
}
div :global(.maplibregl-ctrl-geocoder--input) {
@apply relative;
@apply h-8;
@apply w-64;
@apply py-0;
@apply pl-2;
@@ -16,7 +16,6 @@
</script>
<Button
size="sm"
class="justify-start {className}"
variant="outline"
onclick={() => {
@@ -39,7 +39,6 @@
/>
{#if trackpoint.fileId === undefined}
<Button
size="sm"
variant="outline"
class="justify-start"
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.getLatitude().toFixed(5)}/${trackpoint.item.getLongitude().toFixed(5)}`}
@@ -88,7 +88,6 @@
<CopyCoordinates coordinates={waypoint.item.attributes} />
{#if $currentTool === Tool.WAYPOINT && selected}
<Button
class="p-1 has-[>svg]:px-2 h-8"
variant="outline"
onclick={() => {
if (waypoint.fileId) {
@@ -5,7 +5,7 @@ import { get } from 'svelte/store';
import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden';
import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '../style';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
const { distanceMarkers, distanceUnits } = settings;
@@ -15,7 +15,7 @@ import {
ListFileItem,
ListRootItem,
} from '$lib/components/file-list/file-list';
import { getClosestLinePoint, getElevation } from '$lib/utils';
import { getClosestLinePoint, getElevation, loadSVGIcon } from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
import { MapPin, Square } from 'lucide-static';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
@@ -28,7 +28,7 @@ import { fileActions } from '$lib/logic/file-actions';
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { gpxColors } from './gpx-layers';
import { gpxColors } from '$lib/components/map/gpx-layer/gpx-layers';
const colors = [
'#ff0000',
@@ -174,8 +174,9 @@ export class GPXLayer {
update() {
const _map = get(map);
const layerEventManager = map.layerEventManager;
let file = get(this.file)?.file;
if (!_map || !file) {
if (!_map || !layerEventManager || !file) {
return;
}
@@ -220,12 +221,61 @@ export class GPXLayer {
ANCHOR_LAYER_KEY.tracks
);
_map.on('click', this.fileId, this.layerOnClickBinded);
_map.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
_map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
_map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
_map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
layerEventManager.on('click', this.fileId, this.layerOnClickBinded);
layerEventManager.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
layerEventManager.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
layerEventManager.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
layerEventManager.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
}
let visibleTrackSegmentIds: string[] = [];
file.forEachSegment((segment, trackIndex, segmentIndex) => {
if (!segment._data.hidden) {
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
}
});
const segmentFilter: FilterSpecification = [
'in',
['get', 'trackSegmentId'],
['literal', visibleTrackSegmentIds],
];
_map.setFilter(this.fileId, segmentFilter, { validate: false });
if (get(directionMarkers)) {
if (!_map.getLayer(this.fileId + '-direction')) {
_map.addLayer(
{
id: this.fileId + '-direction',
type: 'symbol',
source: this.fileId,
layout: {
'text-field': '»',
'text-offset': [0, -0.06],
'text-keep-upright': false,
'text-max-angle': 361,
'text-allow-overlap': true,
'text-font': ['Open Sans Bold'],
'symbol-placement': 'line',
'symbol-spacing': 20,
},
paint: {
'text-color': 'white',
'text-halo-width': 0.2,
'text-halo-color': 'white',
},
},
ANCHOR_LAYER_KEY.directionMarkers
);
}
_map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false });
} else {
if (_map.getLayer(this.fileId + '-direction')) {
_map.removeLayer(this.fileId + '-direction');
}
}
let waypointSource = _map.getSource(this.fileId + '-waypoints') as
| GeoJSONSource
| undefined;
@@ -236,6 +286,7 @@ export class GPXLayer {
_map.addSource(this.fileId + '-waypoints', {
type: 'geojson',
data: this.currentWaypointData,
promoteId: 'waypointIndex',
});
}
@@ -256,80 +307,33 @@ export class GPXLayer {
ANCHOR_LAYER_KEY.waypoints
);
_map.on(
layerEventManager.on(
'mouseenter',
this.fileId + '-waypoints',
this.waypointLayerOnMouseEnterBinded
);
_map.on(
layerEventManager.on(
'mouseleave',
this.fileId + '-waypoints',
this.waypointLayerOnMouseLeaveBinded
);
_map.on('click', this.fileId + '-waypoints', this.waypointLayerOnClickBinded);
_map.on(
layerEventManager.on(
'click',
this.fileId + '-waypoints',
this.waypointLayerOnClickBinded
);
layerEventManager.on(
'mousedown',
this.fileId + '-waypoints',
this.waypointLayerOnMouseDownBinded
);
_map.on(
layerEventManager.on(
'touchstart',
this.fileId + '-waypoints',
this.waypointLayerOnTouchStartBinded
);
}
if (get(directionMarkers)) {
if (!_map.getLayer(this.fileId + '-direction')) {
_map.addLayer(
{
id: this.fileId + '-direction',
type: 'symbol',
source: this.fileId,
layout: {
'text-field': '»',
'text-offset': [0, -0.1],
'text-keep-upright': false,
'text-max-angle': 361,
'text-allow-overlap': true,
'text-font': ['Open Sans Bold'],
'symbol-placement': 'line',
'symbol-spacing': 20,
},
paint: {
'text-color': 'white',
'text-opacity': 0.7,
'text-halo-width': 0.2,
'text-halo-color': 'white',
},
},
ANCHOR_LAYER_KEY.directionMarkers
);
}
} else {
if (_map.getLayer(this.fileId + '-direction')) {
_map.removeLayer(this.fileId + '-direction');
}
}
let visibleTrackSegmentIds: string[] = [];
file.forEachSegment((segment, trackIndex, segmentIndex) => {
if (!segment._data.hidden) {
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
}
});
const segmentFilter: FilterSpecification = [
'in',
['get', 'trackSegmentId'],
['literal', visibleTrackSegmentIds],
];
_map.setFilter(this.fileId, segmentFilter, { validate: false });
if (_map.getLayer(this.fileId + '-direction')) {
_map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false });
}
let visibleWaypoints: number[] = [];
file.wpt.forEach((waypoint, waypointIndex) => {
if (!waypoint._data.hidden) {
@@ -350,32 +354,47 @@ export class GPXLayer {
remove() {
const _map = get(map);
if (_map) {
_map.off('click', this.fileId, this.layerOnClickBinded);
_map.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
_map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
_map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
_map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
_map.off('style.load', this.updateBinded);
_map.off(
if (_map) {
_map.off('style.load', this.updateBinded);
}
const layerEventManager = map.layerEventManager;
if (layerEventManager) {
layerEventManager.off('click', this.fileId, this.layerOnClickBinded);
layerEventManager.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
layerEventManager.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
layerEventManager.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
layerEventManager.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
layerEventManager.off(
'mouseenter',
this.fileId + '-waypoints',
this.waypointLayerOnMouseEnterBinded
);
_map.off(
layerEventManager.off(
'mouseleave',
this.fileId + '-waypoints',
this.waypointLayerOnMouseLeaveBinded
);
_map.off('click', this.fileId + '-waypoints', this.waypointLayerOnClickBinded);
_map.off('mousedown', this.fileId + '-waypoints', this.waypointLayerOnMouseDownBinded);
_map.off(
layerEventManager.off(
'click',
this.fileId + '-waypoints',
this.waypointLayerOnClickBinded
);
layerEventManager.off(
'mousedown',
this.fileId + '-waypoints',
this.waypointLayerOnMouseDownBinded
);
layerEventManager.off(
'touchstart',
this.fileId + '-waypoints',
this.waypointLayerOnTouchStartBinded
);
}
if (_map) {
if (_map.getLayer(this.fileId + '-direction')) {
_map.removeLayer(this.fileId + '-direction');
}
@@ -581,6 +600,7 @@ export class GPXLayer {
}
e.preventDefault();
_map.dragPan.disable();
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
this.draggingStartingPosition = e.point;
@@ -604,6 +624,7 @@ export class GPXLayer {
waypointPopup?.hide();
e.preventDefault();
_map.dragPan.disable();
_map.on('touchmove', this.waypointLayerOnMouseMoveBinded);
_map.once('touchend', this.waypointLayerOnMouseUpBinded);
@@ -624,15 +645,32 @@ export class GPXLayer {
| GeoJSONSource
| undefined;
if (waypointSource) {
waypointSource.setData(this.currentWaypointData!);
waypointSource.updateData({
update: [
{
id: this.draggedWaypointIndex,
newGeometry: {
type: 'Point',
coordinates: [e.lngLat.lng, e.lngLat.lat],
},
},
],
});
}
}
waypointLayerOnMouseUp(e: MapLayerMouseEvent | MapLayerTouchEvent) {
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
get(map)?.off('mousemove', this.waypointLayerOnMouseMoveBinded);
get(map)?.off('touchmove', this.waypointLayerOnMouseMoveBinded);
const _map = get(map);
if (!_map) {
return;
}
_map.dragPan.enable();
_map.off('mousemove', this.waypointLayerOnMouseMoveBinded);
_map.off('touchmove', this.waypointLayerOnMouseMoveBinded);
if (this.draggedWaypointIndex === null) {
return;
@@ -755,20 +793,7 @@ export class GPXLayer {
symbols.forEach((symbol) => {
const iconId = `waypoint-${symbol ?? 'default'}-${this.layerColor}`;
if (!_map.hasImage(iconId)) {
let icon = new Image(100, 100);
icon.onload = () => {
if (!_map.hasImage(iconId)) {
_map.addImage(iconId, icon);
}
};
// Lucide icons are SVG files with a 24x24 viewBox
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
icon.src =
'data:image/svg+xml,' +
encodeURIComponent(getSvgForSymbol(symbol, this.layerColor));
}
loadSVGIcon(_map, iconId, getSvgForSymbol(symbol, this.layerColor));
});
}
}
@@ -1,30 +1,40 @@
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
import maplibregl from 'maplibre-gl';
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
import type { GeoJSONSource } from 'maplibre-gl';
import { get } from 'svelte/store';
import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { loadSVGIcon } from '$lib/utils';
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6" fill="#22c55e" stroke="white" stroke-width="1.5"/>
</svg>`;
const endMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="checkerboard" x="0" y="0" width="5" height="5" patternUnits="userSpaceOnUse">
<rect x="0" y="0" width="2.5" height="2.5" fill="white"/>
<rect x="2.5" y="2.5" width="2.5" height="2.5" fill="white"/>
<rect x="2.5" y="0" width="2.5" height="2.5" fill="black"/>
<rect x="0" y="2.5" width="2.5" height="2.5" fill="black"/>
</pattern>
</defs>
<circle cx="8" cy="8" r="6" fill="url(#checkerboard)" stroke="white" stroke-width="1.5"/>
</svg>`;
const hoverMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6" fill="#00b8db" stroke="white" stroke-width="1.5"/>
</svg>`;
export class StartEndMarkers {
start: maplibregl.Marker;
end: maplibregl.Marker;
updateBinded: () => void = this.update.bind(this);
unsubscribes: (() => void)[] = [];
constructor() {
let startElement = document.createElement('div');
let endElement = document.createElement('div');
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
endElement.className = `h-4 w-4 rounded-full border-2 border-white`;
endElement.style.background =
'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
this.start = new maplibregl.Marker({ element: startElement });
this.end = new maplibregl.Marker({ element: endElement });
map.onLoad(() => this.update());
map.onLoad((map_) => map_.on('style.load', this.updateBinded));
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(hoveredPoint.subscribe(this.updateBinded));
this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
}
@@ -33,33 +43,115 @@ export class StartEndMarkers {
const map_ = get(map);
if (!map_) return;
this.loadIcons();
const tool = get(currentTool);
const statistics = get(gpxStatistics);
const slicedStatistics = get(slicedGPXStatistics);
const hovered = get(hoveredPoint);
const hidden = get(allHidden);
if (statistics.global.length > 0 && tool !== Tool.ROUTING && !hidden) {
this.start
.setLngLat(
statistics.getTrackPoint(slicedStatistics?.[1] ?? 0)!.trkpt.getCoordinates()
)
.addTo(map_);
this.end
.setLngLat(
statistics
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
.trkpt.getCoordinates()
)
.addTo(map_);
if (!hidden) {
const data: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
};
if (statistics.global.length > 0 && tool !== Tool.ROUTING) {
const start = statistics
.getTrackPoint(slicedStatistics?.[1] ?? 0)!
.trkpt.getCoordinates();
const end = statistics
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
.trkpt.getCoordinates();
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [start.lon, start.lat],
},
properties: {
icon: 'start-marker',
},
});
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [end.lon, end.lat],
},
properties: {
icon: 'end-marker',
},
});
}
if (hovered) {
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [hovered.lon, hovered.lat],
},
properties: {
icon: 'hover-marker',
},
});
}
let source = map_.getSource('start-end-markers') as GeoJSONSource | undefined;
if (source) {
source.setData(data);
} else {
map_.addSource('start-end-markers', {
type: 'geojson',
data: data,
});
}
if (!map_.getLayer('start-end-markers')) {
map_.addLayer(
{
id: 'start-end-markers',
type: 'symbol',
source: 'start-end-markers',
layout: {
'icon-image': ['get', 'icon'],
'icon-size': 0.2,
'icon-allow-overlap': true,
},
},
ANCHOR_LAYER_KEY.startEndMarkers
);
}
} else {
this.start.remove();
this.end.remove();
if (map_.getLayer('start-end-markers')) {
map_.removeLayer('start-end-markers');
}
if (map_.getSource('start-end-markers')) {
map_.removeSource('start-end-markers');
}
}
}
remove() {
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
this.start.remove();
this.end.remove();
const map_ = get(map);
if (!map_) return;
if (map_.getLayer('start-end-markers')) {
map_.removeLayer('start-end-markers');
}
if (map_.getSource('start-end-markers')) {
map_.removeSource('start-end-markers');
}
}
loadIcons() {
const map_ = get(map);
if (!map_) return;
loadSVGIcon(map_, 'start-marker', startMarkerSVG);
loadSVGIcon(map_, 'end-marker', endMarkerSVG);
loadSVGIcon(map_, 'hover-marker', hoverMarkerSVG);
}
}
@@ -20,9 +20,8 @@
import { i18n } from '$lib/i18n.svelte';
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
import { onMount } from 'svelte';
import { customBasemapUpdate, isSelected, remove } from './utils';
import { remove } from './utils';
import { settings } from '$lib/logic/settings';
import { map } from '$lib/components/map/map';
import { dndzone } from 'svelte-dnd-action';
const {
@@ -129,8 +128,8 @@
],
};
}
$customLayers[layerId] = layer;
addLayer(layerId);
$customLayers[layerId] = layer;
selectedLayerId = undefined;
setDataFromSelectedLayer();
}
@@ -153,9 +152,7 @@
return $tree;
});
if ($currentBasemap === layerId) {
$customBasemapUpdate++;
} else {
if ($currentBasemap !== layerId) {
$currentBasemap = layerId;
}
@@ -171,14 +168,6 @@
return $tree;
});
if ($map && $currentOverlays && isSelected($currentOverlays, layerId)) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
currentOverlays.update(($overlays) => {
if (!$overlays.overlays.hasOwnProperty('custom')) {
$overlays.overlays['custom'] = {};
@@ -243,120 +232,127 @@
<div class="flex flex-col">
{#if $customBasemapOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Map size="16" />
{i18n._('layers.label.basemaps')}
<div class="grow">
<Separator />
<div class="px-3 py-2">
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Map size="16" />
{i18n._('layers.label.basemaps')}
</div>
<div
class="ml-1.5 flex flex-col gap-1"
use:dndzone={{
items: customBasemapItems,
type: 'basemap',
dropTargetStyle: {},
transformDraggedElement: (element) => {
if (element) {
element.style.opacity = '0.5';
}
},
}}
onconsider={(e) => {
customBasemapItems = e.detail.items;
}}
onfinalize={(e) => {
customBasemapItems = e.detail.items;
$customBasemapOrder = customBasemapItems.map((item) => item.id);
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce(
(acc, item) => {
acc[item.id] = true;
return acc;
},
{}
);
}}
>
{#each customBasemapItems as item (item.id)}
<div class="flex flex-row items-center gap-1">
<Move size="12" />
<span class="grow">{item.name}</span>
<Button
variant="outline"
size="icon-sm"
onclick={() => (selectedLayerId = item.id)}
class="p-1 h-7"
>
<Pencil size="16" />
</Button>
<Button
variant="outline"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
</div>
<Separator />
{/if}
<div
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
use:dndzone={{
items: customBasemapItems,
type: 'basemap',
dropTargetStyle: {},
transformDraggedElement: (element) => {
if (element) {
element.style.opacity = '0.5';
}
},
}}
onconsider={(e) => {
customBasemapItems = e.detail.items;
}}
onfinalize={(e) => {
customBasemapItems = e.detail.items;
$customBasemapOrder = customBasemapItems.map((item) => item.id);
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce((acc, item) => {
acc[item.id] = true;
return acc;
}, {});
}}
>
{#each customBasemapItems as item (item.id)}
<div class="flex flex-row items-center gap-2">
<Move size="12" />
<span class="grow">{item.name}</span>
<Button
variant="outline"
size="icon-sm"
onclick={() => (selectedLayerId = item.id)}
class="p-1 h-7"
>
<Pencil size="16" />
</Button>
<Button
variant="outline"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
{#if $customOverlayOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Layers2 size="16" />
{i18n._('layers.label.overlays')}
<div class="grow">
<Separator />
<div class="px-3 py-2">
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Layers2 size="16" />
{i18n._('layers.label.overlays')}
<div class="grow"></div>
</div>
<div
class="ml-1.5 flex flex-col gap-1"
use:dndzone={{
items: customOverlayItems,
type: 'overlay',
dropTargetStyle: {},
transformDraggedElement: (element) => {
if (element) {
element.style.opacity = '0.5';
}
},
}}
onconsider={(e) => {
customOverlayItems = e.detail.items;
}}
onfinalize={(e) => {
customOverlayItems = e.detail.items;
$customOverlayOrder = customOverlayItems.map((item) => item.id);
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce(
(acc, item) => {
acc[item.id] = true;
return acc;
},
{}
);
}}
>
{#each customOverlayItems as item (item.id)}
<div class="flex flex-row items-center gap-1">
<Move size="12" />
<span class="grow">{item.name}</span>
<Button
variant="outline"
size="icon-sm"
onclick={() => (selectedLayerId = item.id)}
class="p-1 h-7"
>
<Pencil size="16" />
</Button>
<Button
variant="outline"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
</div>
<Separator />
{/if}
<div
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
use:dndzone={{
items: customOverlayItems,
type: 'overlay',
dropTargetStyle: {},
transformDraggedElement: (element) => {
if (element) {
element.style.opacity = '0.5';
}
},
}}
onconsider={(e) => {
customOverlayItems = e.detail.items;
}}
onfinalize={(e) => {
customOverlayItems = e.detail.items;
$customOverlayOrder = customOverlayItems.map((item) => item.id);
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce((acc, item) => {
acc[item.id] = true;
return acc;
}, {});
}}
>
{#each customOverlayItems as item (item.id)}
<div class="flex flex-row items-center gap-2">
<Move size="12" />
<span class="grow">{item.name}</span>
<Button
variant="outline"
size="icon-sm"
onclick={() => (selectedLayerId = item.id)}
class="p-1 h-7"
>
<Pencil size="16" />
</Button>
<Button
variant="outline"
size="icon-sm"
onclick={() => deleteLayer(item.id)}
class="p-1 h-7"
>
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
<Card.Root class="py-0 gap-0 shadow-none">
<Card.Root class="py-0 gap-0 shadow-none ring-0">
<Card.Header class="p-3">
<Card.Title class="text-base">
<Card.Title class="text-sm font-semibold">
{#if selectedLayerId}
{i18n._('layers.custom_layers.edit')}
{:else}
@@ -364,7 +360,7 @@
{/if}
</Card.Title>
</Card.Header>
<Card.Content class="p-3 pt-0">
<Card.Content class="px-3 py-2">
<fieldset class="flex flex-col gap-2">
<Label for="name">{i18n._('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="h-8" />
@@ -421,7 +417,7 @@
</div>
</RadioGroup.Root>
{#if selectedLayerId}
<div class="mt-2 flex flex-row gap-2">
<div class="mt-2 flex flex-row gap-1">
<Button variant="outline" onclick={createLayer} class="grow">
<Save size="16" />
{i18n._('layers.custom_layers.update')}
@@ -25,7 +25,7 @@
if (overpassLayer) {
overpassLayer.remove();
}
overpassLayer = new OverpassLayer(_map);
overpassLayer = new OverpassLayer(_map, map.layerEventManager!);
overpassLayer.add();
});
@@ -121,7 +121,7 @@
<Accordion.Root class="flex flex-col" bind:value={accordionValue} type="single">
<Accordion.Item value="layer-selection" class="flex flex-col">
<Accordion.Trigger>{i18n._('layers.selection')}</Accordion.Trigger>
<Accordion.Content class="grow flex flex-col border rounded">
<Accordion.Content class="grow flex flex-col border rounded-md mb-1.5">
<div class="py-2 pl-3 pr-2">
<LayerTree
layerTree={basemapTree}
@@ -152,7 +152,9 @@
</Accordion.Item>
<Accordion.Item value="overlay-opacity">
<Accordion.Trigger>{i18n._('layers.opacity')}</Accordion.Trigger>
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
<Accordion.Content
class="flex flex-col gap-3 overflow-visible border rounded-md px-3 py-2 mb-1.5"
>
<div class="flex flex-row gap-6 items-center">
<Label>
{i18n._('layers.custom_layers.overlay')}
@@ -167,11 +169,11 @@
{#if isSelected($selectedOverlayTree, selectedOverlay)}
{#if $isLayerFromExtension(selectedOverlay)}
{$getLayerName(selectedOverlay)}
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
{$customLayers[selectedOverlay].name}
{:else}
{i18n._(`layers.label.${selectedOverlay}`)}
{/if}
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
{$customLayers[selectedOverlay].name}
{/if}
{/if}
</Select.Trigger>
@@ -231,10 +233,10 @@
<Accordion.Item value="custom-layers">
<Accordion.Trigger>{i18n._('layers.custom_layers.title')}</Accordion.Trigger
>
<Accordion.Content>
<ScrollArea>
<CustomLayers />
</ScrollArea>
<Accordion.Content
class="flex flex-col overflow-visible border rounded-md p-0 mb-1.5"
>
<CustomLayers />
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="terrain-source">
@@ -7,7 +7,9 @@ import { MapPopup } from '$lib/components/map/map-popup';
import { settings } from '$lib/logic/settings';
import { db } from '$lib/db';
import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '../style';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
const { currentOverpassQueries } = settings;
@@ -22,11 +24,12 @@ liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
});
export class OverpassLayer {
overpassUrl = 'https://maps.mail.ru/osm/tools/overpass/api/interpreter';
overpassUrl = 'https://overpass.private.coffee/api/interpreter';
minZoom = 12;
queryZoom = 12;
expirationTime = 7 * 24 * 3600 * 1000;
map: maplibregl.Map;
layerEventManager: MapLayerEventManager;
popup: MapPopup;
currentQueries: Set<string> = new Set();
@@ -37,8 +40,9 @@ export class OverpassLayer {
updateBinded = this.update.bind(this);
onHoverBinded = this.onHover.bind(this);
constructor(map: maplibregl.Map) {
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
this.map = map;
this.layerEventManager = layerEventManager;
this.popup = new MapPopup(map, {
closeButton: false,
focusAfterOpen: false,
@@ -73,7 +77,14 @@ export class OverpassLayer {
update() {
this.loadIcons();
let d = get(data);
const fullData = get(data);
const queries = getCurrentQueries();
const d: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: fullData.features.filter((feature) =>
queries.includes(feature.properties!.query)
),
};
try {
let source = this.map.getSource('overpass') as GeoJSONSource | undefined;
@@ -102,13 +113,9 @@ export class OverpassLayer {
ANCHOR_LAYER_KEY.overpass
);
this.map.on('mouseenter', 'overpass', this.onHoverBinded);
this.map.on('click', 'overpass', this.onHoverBinded);
this.layerEventManager.on('mouseenter', 'overpass', this.onHoverBinded);
this.layerEventManager.on('click', 'overpass', this.onHoverBinded);
}
this.map.setFilter('overpass', ['in', 'query', ...getCurrentQueries()], {
validate: false,
});
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
@@ -117,6 +124,8 @@ export class OverpassLayer {
remove() {
this.map.off('moveend', this.queryIfNeededBinded);
this.map.off('style.load', this.updateBinded);
this.layerEventManager.off('mouseenter', 'overpass', this.onHoverBinded);
this.layerEventManager.off('click', 'overpass', this.onHoverBinded);
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
try {
@@ -249,27 +258,16 @@ export class OverpassLayer {
loadIcons() {
let currentQueries = getCurrentQueries();
currentQueries.forEach((query) => {
if (!this.map.hasImage(`overpass-${query}`)) {
let icon = new Image(100, 100);
icon.onload = () => {
if (!this.map.hasImage(`overpass-${query}`)) {
this.map.addImage(`overpass-${query}`, icon);
}
};
// Lucide icons are SVG files with a 24x24 viewBox
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
icon.src =
'data:image/svg+xml,' +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
loadSVGIcon(
this.map,
`overpass-${query}`,
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
<g transform="translate(8 8)">
${overpassQueryData[query].icon.svg.replace('stroke="currentColor"', 'stroke="white"')}
</g>
</svg>
`);
}
</svg>`
);
});
}
}
@@ -1,5 +1,4 @@
import type { LayerTreeType } from '$lib/assets/layers';
import { writable } from 'svelte/store';
export function anySelectedLayer(node: LayerTreeType) {
return (
@@ -76,5 +75,3 @@ export function removeAll(node: LayerTreeType, ids: string[]) {
});
return node;
}
export const customBasemapUpdate = writable(0);
@@ -0,0 +1,282 @@
import { fileStateCollection } from '$lib/logic/file-state';
import maplibregl from 'maplibre-gl';
type MapLayerMouseEventListener = (e: maplibregl.MapLayerMouseEvent) => void;
type MapLayerTouchEventListener = (e: maplibregl.MapLayerTouchEvent) => void;
type MapLayerListener = {
features: maplibregl.MapGeoJSONFeature[];
mousemoves: MapLayerMouseEventListener[];
mouseenters: MapLayerMouseEventListener[];
mouseleaves: MapLayerMouseEventListener[];
mousedowns: MapLayerMouseEventListener[];
clicks: MapLayerMouseEventListener[];
contextmenus: MapLayerMouseEventListener[];
touchstarts: MapLayerTouchEventListener[];
};
export class MapLayerEventManager {
private _map: maplibregl.Map;
private _listeners: Record<string, MapLayerListener> = {};
constructor(map: maplibregl.Map) {
this._map = map;
this._map.on('mousemove', this._handleMouseMove.bind(this));
this._map.on('click', this._handleMouseClick.bind(this, 'click'));
this._map.on('contextmenu', this._handleMouseClick.bind(this, 'contextmenu'));
this._map.on('mousedown', this._handleMouseClick.bind(this, 'mousedown'));
this._map.on('touchstart', this._handleTouchStart.bind(this));
}
on(
eventType:
| 'mousemove'
| 'mouseenter'
| 'mouseleave'
| 'mousedown'
| 'click'
| 'contextmenu'
| 'touchstart',
layerId: string,
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
) {
if (!this._listeners[layerId]) {
this._listeners[layerId] = {
features: [],
mousemoves: [],
mouseenters: [],
mouseleaves: [],
mousedowns: [],
clicks: [],
contextmenus: [],
touchstarts: [],
};
}
switch (eventType) {
case 'mousemove':
this._listeners[layerId].mousemoves.push(listener as MapLayerMouseEventListener);
break;
case 'mouseenter':
this._listeners[layerId].mouseenters.push(listener as MapLayerMouseEventListener);
break;
case 'mouseleave':
this._listeners[layerId].mouseleaves.push(listener as MapLayerMouseEventListener);
break;
case 'mousedown':
this._listeners[layerId].mousedowns.push(listener as MapLayerMouseEventListener);
break;
case 'click':
this._listeners[layerId].clicks.push(listener as MapLayerMouseEventListener);
break;
case 'contextmenu':
this._listeners[layerId].contextmenus.push(listener as MapLayerMouseEventListener);
break;
case 'touchstart':
this._listeners[layerId].touchstarts.push(listener as MapLayerTouchEventListener);
break;
}
}
off(
eventType:
| 'mousemove'
| 'mouseenter'
| 'mouseleave'
| 'mousedown'
| 'click'
| 'contextmenu'
| 'touchstart',
layerId: string,
listener: MapLayerMouseEventListener | MapLayerTouchEventListener
) {
if (this._listeners[layerId]) {
switch (eventType) {
case 'mousemove':
this._listeners[layerId].mousemoves = this._listeners[
layerId
].mousemoves.filter((l) => l !== listener);
break;
case 'mouseenter':
this._listeners[layerId].mouseenters = this._listeners[
layerId
].mouseenters.filter((l) => l !== listener);
break;
case 'mouseleave':
this._listeners[layerId].mouseleaves = this._listeners[
layerId
].mouseleaves.filter((l) => l !== listener);
break;
case 'mousedown':
this._listeners[layerId].mousedowns = this._listeners[
layerId
].mousedowns.filter((l) => l !== listener);
break;
case 'click':
this._listeners[layerId].clicks = this._listeners[layerId].clicks.filter(
(l) => l !== listener
);
break;
case 'contextmenu':
this._listeners[layerId].contextmenus = this._listeners[
layerId
].contextmenus.filter((l) => l !== listener);
break;
case 'touchstart':
this._listeners[layerId].touchstarts = this._listeners[
layerId
].touchstarts.filter((l) => l !== listener);
break;
}
if (
this._listeners[layerId].mousemoves.length === 0 &&
this._listeners[layerId].mouseenters.length === 0 &&
this._listeners[layerId].mouseleaves.length === 0 &&
this._listeners[layerId].mousedowns.length === 0 &&
this._listeners[layerId].clicks.length === 0 &&
this._listeners[layerId].contextmenus.length === 0 &&
this._listeners[layerId].touchstarts.length === 0
) {
delete this._listeners[layerId];
}
}
}
private _handleMouseMove(e: maplibregl.MapMouseEvent) {
if (e.originalEvent.buttons > 0) return;
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId];
if ((features.length == 0) != (listener.features.length == 0)) {
if (features.length > 0) {
if (listener.mouseenters.length > 0) {
const event = new maplibregl.MapMouseEvent(
'mouseenter',
e.target,
e.originalEvent,
{
features: featuresByLayer[layerId]!,
}
);
listener.mouseenters.forEach((l) => l(event));
}
} else {
if (listener.mouseleaves.length > 0) {
const event = new maplibregl.MapMouseEvent(
'mouseleave',
e.target,
e.originalEvent
);
listener.mouseleaves.forEach((l) => l(event));
}
}
}
if (features.length > 0 && listener.mousemoves.length > 0) {
const event = new maplibregl.MapMouseEvent('mousemove', e.target, e.originalEvent, {
features: featuresByLayer[layerId]!,
});
listener.mousemoves.forEach((l) => l(event));
}
listener.features = features;
});
}
private _handleMouseClick(type: string, e: maplibregl.MapMouseEvent) {
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId];
if (features.length > 0) {
if (type === 'click' && listener.clicks.length > 0) {
const event = new maplibregl.MapMouseEvent('click', e.target, e.originalEvent, {
features: features,
});
listener.clicks.forEach((l) => l(event));
} else if (type === 'contextmenu' && listener.contextmenus.length > 0) {
const event = new maplibregl.MapMouseEvent(
'contextmenu',
e.target,
e.originalEvent,
{
features: features,
}
);
listener.contextmenus.forEach((l) => l(event));
} else if (type === 'mousedown' && listener.mousedowns.length > 0) {
const event = new maplibregl.MapMouseEvent(
'mousedown',
e.target,
e.originalEvent,
{
features: features,
}
);
listener.mousedowns.forEach((l) => l(event));
}
}
});
}
private _handleTouchStart(e: maplibregl.MapTouchEvent) {
const featuresByLayer = this._getRenderedFeaturesByLayer(e);
Object.keys(this._listeners).forEach((layerId) => {
const features = featuresByLayer[layerId] || [];
const listener = this._listeners[layerId];
if (features.length > 0) {
const event: maplibregl.MapLayerTouchEvent = new maplibregl.MapTouchEvent(
'touchstart',
e.target,
e.originalEvent
);
event.features = featuresByLayer[layerId]!;
listener.touchstarts.forEach((l) => l(event));
}
});
}
private _getBounds(point: maplibregl.Point) {
const delta = 30;
return new maplibregl.LngLatBounds(
this._map.unproject([point.x - delta, point.y + delta]),
this._map.unproject([point.x + delta, point.y - delta])
);
}
private _filterLayersIntersectingBounds(
layerIds: string[],
bounds: maplibregl.LngLatBounds
): string[] {
let result = layerIds.filter((layerId) => {
if (!this._map.getLayer(layerId)) return false;
const fileId = layerId.replace('-waypoints', '');
if (fileId === layerId) {
return fileStateCollection.getStatistics(fileId)?.intersectsBBox(bounds) ?? true;
} else {
return (
fileStateCollection.getStatistics(fileId)?.intersectsWaypointBBox(bounds) ??
true
);
}
});
return result;
}
private _getRenderedFeaturesByLayer(e: maplibregl.MapMouseEvent | maplibregl.MapTouchEvent) {
const layerIds = this._filterLayersIntersectingBounds(
Object.keys(this._listeners),
this._getBounds(e.point)
);
const features =
layerIds.length > 0
? this._map.queryRenderedFeatures(e.point, { layers: layerIds })
: [];
const featuresByLayer: Record<string, maplibregl.MapGeoJSONFeature[]> = {};
features.forEach((f) => {
if (!featuresByLayer[f.layer.id]) {
featuresByLayer[f.layer.id] = [];
}
featuresByLayer[f.layer.id].push(f);
});
return featuresByLayer;
}
}
+4 -1
View File
@@ -8,6 +8,7 @@ import { get, writable, type Writable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
import { tick } from 'svelte';
import { ANCHOR_LAYER_KEY, StyleManager } from '$lib/components/map/style';
import { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings;
@@ -25,6 +26,7 @@ export class MapLibreGLMap {
private _onLoadCallbacks: ((map: maplibregl.Map) => void)[] = [];
private _unsubscribes: (() => void)[] = [];
private callOnLoadBinded: () => void = this.callOnLoad.bind(this);
public layerEventManager: MapLayerEventManager | null = null;
subscribe(run: (value: maplibregl.Map | null) => void, invalidate?: () => void) {
return this._mapStore.subscribe(run, invalidate);
@@ -52,8 +54,9 @@ export class MapLibreGLMap {
zoom: 0,
hash: hash,
boxZoom: false,
maxPitch: 85,
maxPitch: 90,
});
this.layerEventManager = new MapLayerEventManager(map);
map.addControl(
new maplibregl.NavigationControl({
visualizePitch: true,
@@ -20,9 +20,14 @@
let container: HTMLElement;
onMount(() => {
map.onLoad((map: maplibregl.Map) => {
googleRedirect = new GoogleRedirect(map);
mapillaryLayer = new MapillaryLayer(map, container, mapillaryOpen);
map.onLoad((map_: maplibregl.Map) => {
googleRedirect = new GoogleRedirect(map_);
mapillaryLayer = new MapillaryLayer(
map_,
map.layerEventManager!,
container,
mapillaryOpen
);
});
});
@@ -48,7 +53,7 @@
<CustomControl class="w-[29px] h-[29px] shrink-0">
<ButtonWithTooltip
variant="ghost"
class="w-full h-full"
class="w-full h-full border-none rounded-sm"
side="left"
label={i18n._('menu.toggle_street_view')}
onclick={() => {
@@ -2,7 +2,8 @@ import maplibregl, { type LayerSpecification, type VectorSourceSpecification } f
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
import 'mapillary-js/dist/mapillary.css';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { ANCHOR_LAYER_KEY } from '../style';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
const mapillarySource: VectorSourceSpecification = {
type: 'vector',
@@ -43,6 +44,7 @@ const mapillaryImageLayer: LayerSpecification = {
export class MapillaryLayer {
map: maplibregl.Map;
layerEventManager: MapLayerEventManager;
marker: maplibregl.Marker;
viewer: Viewer;
@@ -53,8 +55,14 @@ export class MapillaryLayer {
onMouseEnterBinded = this.onMouseEnter.bind(this);
onMouseLeaveBinded = this.onMouseLeave.bind(this);
constructor(map: maplibregl.Map, container: HTMLElement, popupOpen: { value: boolean }) {
constructor(
map: maplibregl.Map,
layerEventManager: MapLayerEventManager,
container: HTMLElement,
popupOpen: { value: boolean }
) {
this.map = map;
this.layerEventManager = layerEventManager;
this.viewer = new Viewer({
accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011',
@@ -103,14 +111,14 @@ export class MapillaryLayer {
this.map.addLayer(mapillaryImageLayer, ANCHOR_LAYER_KEY.mapillary);
}
this.map.on('style.load', this.addBinded);
this.map.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
this.map.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
this.layerEventManager.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
this.layerEventManager.on('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
}
remove() {
this.map.off('style.load', this.addBinded);
this.map.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
this.map.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
this.layerEventManager.off('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
this.layerEventManager.off('mouseleave', 'mapillary-image', this.onMouseLeaveBinded);
if (this.map.getLayer('mapillary-image')) {
this.map.removeLayer('mapillary-image');
+120 -78
View File
@@ -7,10 +7,11 @@ import {
overlays,
terrainSources,
} from '$lib/assets/layers';
import { customBasemapUpdate, getLayers } from '$lib/components/map/layer-control/utils';
import { getLayers } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n.svelte';
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource } = settings;
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } =
settings;
const emptySource: maplibregl.GeoJSONSourceSpecification = {
type: 'geojson',
@@ -25,9 +26,11 @@ export const ANCHOR_LAYER_KEY = {
tracks: 'tracks-end',
directionMarkers: 'direction-markers-end',
distanceMarkers: 'distance-markers-end',
startEndMarkers: 'start-end-markers-end',
interactions: 'interactions-end',
overpass: 'overpass-end',
waypoints: 'waypoints-end',
routingControls: 'routing-controls-end',
};
const anchorLayers: maplibregl.LayerSpecification[] = Object.values(ANCHOR_LAYER_KEY).map((id) => ({
id: id,
@@ -45,22 +48,73 @@ export class StyleManager {
this._maptilerKey = maptilerKey;
this._map.subscribe((map_) => {
if (map_) {
this.update();
this.updateBasemap();
map_.on('style.load', () => this.updateOverlays());
map_.on('pitch', () => this.updateTerrain());
}
});
currentBasemap.subscribe(() => this.update());
customBasemapUpdate.subscribe(() => this.update());
currentBasemap.subscribe(() => this.updateBasemap());
currentOverlays.subscribe(() => this.updateOverlays());
opacities.subscribe(() => this.updateOverlays());
terrainSource.subscribe(() => this.updateTerrain());
customLayers.subscribe(() => this.updateBasemap());
distanceUnits.subscribe(() => {
if (get(currentBasemap) === 'topo') this.updateBasemap();
});
}
update() {
updateBasemap() {
const map_ = get(this._map);
if (!map_) return;
this.build().then((style) => map_.setStyle(style));
let basemap = get(currentBasemap);
this.buildStyle(basemap).then((style) => {
if (get(currentBasemap) === basemap) map_.setStyle(style);
});
}
async buildStyle(basemap: string): Promise<maplibregl.StyleSpecification> {
const custom = get(customLayers);
const style: maplibregl.StyleSpecification = {
version: 8,
projection: {
type: 'globe',
},
sources: {
'empty-source': emptySource,
},
layers: [],
};
const basemapInfo = basemaps[basemap] ?? custom[basemap]?.value ?? basemaps[defaultBasemap];
let basemapStyle = basemaps.openStreetMap as maplibregl.StyleSpecification;
try {
basemapStyle = await this.get(basemapInfo);
for (const source in basemapStyle.sources) {
const src = basemapStyle.sources[source];
if (
src &&
typeof src === 'object' &&
'url' in src &&
typeof src.url === 'string' &&
src.url.includes(maptilerKeyPlaceHolder)
) {
src.url = src.url.replace(maptilerKeyPlaceHolder, this._maptilerKey);
}
}
} catch (e) {
console.error(e.message);
}
this.merge(style, basemapStyle);
const terrain = this.getCurrentTerrain();
style.sources[terrain.source] = terrainSources[terrain.source];
style.terrain = terrain.exaggeration > 0 ? terrain : undefined;
style.layers.push(...anchorLayers);
return style;
}
async updateOverlays() {
@@ -76,45 +130,52 @@ export class StyleManager {
if (!layers[overlay]) {
if (this._pastOverlays.has(overlay)) {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
const overlayStyle = await this.get(overlayInfo);
for (let layer of overlayStyle.layers ?? []) {
if (map_.getLayer(layer.id)) {
map_.removeLayer(layer.id);
try {
const overlayStyle = await this.get(overlayInfo);
for (let layer of overlayStyle.layers ?? []) {
if (map_.getLayer(layer.id)) {
map_.removeLayer(layer.id);
}
}
} catch (e) {
// Should not happen
}
this._pastOverlays.delete(overlay);
}
} else {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
const overlayStyle = await this.get(overlayInfo);
const opacity = overlayOpacities[overlay];
try {
const overlayStyle = await this.get(overlayInfo);
const opacity = overlayOpacities[overlay];
for (let sourceId in overlayStyle.sources) {
if (!map_.getSource(sourceId)) {
map_.addSource(sourceId, overlayStyle.sources[sourceId]);
}
}
for (let layer of overlayStyle.layers ?? []) {
if (!map_.getLayer(layer.id)) {
if (opacity !== undefined) {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = opacity;
} else if (layer.type === 'hillshade') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['hillshade-exaggeration'] = opacity / 2;
}
for (let sourceId in overlayStyle.sources) {
if (!map_.getSource(sourceId)) {
map_.addSource(sourceId, overlayStyle.sources[sourceId]);
}
map_.addLayer(layer, ANCHOR_LAYER_KEY.overlays);
}
}
this._pastOverlays.add(overlay);
for (let layer of overlayStyle.layers ?? []) {
if (!map_.getLayer(layer.id)) {
if (opacity !== undefined) {
if (layer.type === 'raster') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['raster-opacity'] = opacity;
} else if (layer.type === 'hillshade') {
if (!layer.paint) {
layer.paint = {};
}
layer.paint['hillshade-exaggeration'] = opacity / 2;
}
}
map_.addLayer(layer, ANCHOR_LAYER_KEY.overlays);
}
}
this._pastOverlays.add(overlay);
} catch (e) {
console.error(e.message);
}
}
}
} catch (e) {}
@@ -127,47 +188,26 @@ export class StyleManager {
const mapTerrain = map_.getTerrain();
const terrain = this.getCurrentTerrain();
if (JSON.stringify(mapTerrain) !== JSON.stringify(terrain)) {
map_.setTerrain(terrain);
if (terrain.exaggeration > 0) {
if (!map_.getSource(terrain.source)) {
map_.addSource(terrain.source, terrainSources[terrain.source]);
}
map_.setTerrain(terrain);
} else {
map_.setTerrain(null);
}
}
}
async build(): Promise<maplibregl.StyleSpecification> {
const custom = get(customLayers);
const style: maplibregl.StyleSpecification = {
version: 8,
projection: {
type: 'globe',
},
sources: {
'empty-source': emptySource,
},
layers: [],
};
let basemap = get(currentBasemap);
const basemapInfo = basemaps[basemap] ?? custom[basemap]?.value ?? basemaps[defaultBasemap];
const basemapStyle = await this.get(basemapInfo);
this.merge(style, basemapStyle);
style.terrain = this.getCurrentTerrain();
style.sources[style.terrain.source] = terrainSources[style.terrain.source];
style.layers.push(...anchorLayers);
return style;
}
async get(
styleInfo: maplibregl.StyleSpecification | string
): Promise<maplibregl.StyleSpecification> {
if (typeof styleInfo === 'string') {
let styleUrl = styleInfo as string;
if (styleUrl.includes(maptilerKeyPlaceHolder)) {
styleUrl = styleUrl.replace(maptilerKeyPlaceHolder, this._maptilerKey);
}
const response = await fetch(styleUrl, { cache: 'force-cache' });
if (!response.ok) {
throw new Error(`HTTP error fetching style "${styleInfo}": ${response.status}`);
}
const style = await response.json();
return style;
} else {
@@ -177,17 +217,23 @@ export class StyleManager {
merge(style: maplibregl.StyleSpecification, other: maplibregl.StyleSpecification) {
style.sources = { ...style.sources, ...other.sources };
const units = get(distanceUnits);
for (let layer of other.layers ?? []) {
if ('source' in layer) {
if (layer.source == 'contours_m' && units === 'imperial') continue;
if (layer.source == 'contours_ft' && units !== 'imperial') continue;
}
if (layer.type === 'symbol' && layer.layout && layer.layout['text-field']) {
const textField = layer.layout['text-field'];
if (
Array.isArray(textField) &&
textField.length >= 2 &&
textField[0] === 'coalesce' &&
Array.isArray(textField[1]) &&
textField[1][0] === 'get' &&
typeof textField[1][1] === 'string' &&
textField[1][1].startsWith('name')
textField.length == 4 &&
Array.isArray(textField[3]) &&
textField[3][0] === 'coalesce' &&
Array.isArray(textField[3][1]) &&
textField[3][1][0] === 'get' &&
typeof textField[3][1][1] === 'string' &&
textField[3][1][1].startsWith('name')
) {
layer.layout['text-field'] = [
'coalesce',
@@ -208,10 +254,6 @@ export class StyleManager {
getCurrentTerrain() {
const terrain = get(terrainSource);
const source = terrainSources[terrain];
if (source.url && source.url.includes(maptilerKeyPlaceHolder)) {
source.url = source.url.replace(maptilerKeyPlaceHolder, this._maptilerKey);
}
const map_ = get(this._map);
return {
source: terrain,
@@ -17,7 +17,7 @@
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
<Button
variant="outline"
class="whitespace-normal h-fit"
class="whitespace-normal h-fit min-h-8 py-1"
disabled={!validSelection}
onclick={() => fileActions.addElevationToSelection()}
>
@@ -76,7 +76,7 @@
{/if}
<Button
variant="outline"
class="whitespace-normal h-fit"
class="whitespace-normal h-fit min-h-8 py-1"
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
onclick={() => {
@@ -185,8 +185,8 @@
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
<fieldset class="flex flex-col gap-2">
<div class="flex flex-row gap-2 justify-center">
<div class="flex flex-col gap-2 grow">
<div class="flex flex-row gap-1.5 justify-center">
<div class="flex flex-col gap-1 grow">
<Label for="speed" class="flex flex-row">
<Zap size="16" />
{#if $velocityUnits === 'speed'}
@@ -239,7 +239,7 @@
{/if}
</div>
</div>
<div class="flex flex-col gap-2 grow">
<div class="flex flex-col gap-1 grow">
<Label for="duration" class="flex flex-row">
<Timer size="16" />
{i18n._('toolbar.time.total_time')}
@@ -253,57 +253,61 @@
/>
</div>
</div>
<Label class="flex flex-row">
<CirclePlay size="16" />
{i18n._('toolbar.time.start')}
</Label>
<div class="flex flex-row gap-2">
<DatePicker
bind:value={startDate}
disabled={!canUpdate}
locale={i18n.lang}
placeholder={i18n._('toolbar.time.pick_date')}
class="w-fit grow"
onchange={() => {
untrack(() => updateEnd());
}}
/>
<Input
type="time"
step={1}
disabled={!canUpdate}
bind:value={startTime}
class="w-fit"
onchange={() => {
untrack(() => updateEnd());
}}
/>
<div class="flex flex-col gap-1">
<Label class="flex flex-row">
<CirclePlay size="16" />
{i18n._('toolbar.time.start')}
</Label>
<div class="flex flex-row gap-1.5">
<DatePicker
bind:value={startDate}
disabled={!canUpdate}
locale={i18n.lang}
placeholder={i18n._('toolbar.time.pick_date')}
class="w-fit grow"
onchange={() => {
untrack(() => updateEnd());
}}
/>
<Input
type="time"
step={1}
disabled={!canUpdate}
bind:value={startTime}
class="w-fit"
onchange={() => {
untrack(() => updateEnd());
}}
/>
</div>
</div>
<Label class="flex flex-row">
<CircleStop size="16" />
{i18n._('toolbar.time.end')}
</Label>
<div class="flex flex-row gap-2">
<DatePicker
bind:value={endDate}
disabled={!canUpdate}
locale={i18n.lang}
placeholder={i18n._('toolbar.time.pick_date')}
class="w-fit grow"
onchange={() => {
untrack(() => updateStart());
}}
/>
<Input
type="time"
step={1}
disabled={!canUpdate}
bind:value={endTime}
class="w-fit"
onchange={() => {
untrack(() => updateStart());
}}
/>
<div class="flex flex-col gap-1">
<Label class="flex flex-row">
<CircleStop size="16" />
{i18n._('toolbar.time.end')}
</Label>
<div class="flex flex-row gap-1.5">
<DatePicker
bind:value={endDate}
disabled={!canUpdate}
locale={i18n.lang}
placeholder={i18n._('toolbar.time.pick_date')}
class="w-fit grow"
onchange={() => {
untrack(() => updateStart());
}}
/>
<Input
type="time"
step={1}
disabled={!canUpdate}
bind:value={endTime}
class="w-fit"
onchange={() => {
untrack(() => updateStart());
}}
/>
</div>
</div>
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
<div class="mt-0.5 flex flex-row gap-1 items-center">
@@ -314,11 +318,11 @@
</div>
{/if}
</fieldset>
<div class="flex flex-row gap-2 items-center">
<div class="flex flex-row gap-1.5 items-center">
<Button
variant="outline"
disabled={!canUpdate}
class="grow whitespace-normal h-fit"
class="grow shrink whitespace-normal h-fit min-h-8 py-1"
onclick={() => {
let effectiveSpeed = getSpeed();
if (
@@ -14,7 +14,7 @@
let props: { class?: string } = $props();
let sliderValue = $state([50]);
let sliderValue = $state(50);
const maxTolerance = 10000;
let validSelection = $derived(
@@ -25,7 +25,7 @@
$effect(() => {
tolerance.set(
minTolerance * 2 ** (sliderValue[0] / (100 / Math.log2(maxTolerance / minTolerance)))
minTolerance * 2 ** (sliderValue / (100 / Math.log2(maxTolerance / minTolerance)))
);
});
@@ -36,7 +36,7 @@
<div class="flex flex-col gap-3 w-full max-w-80 {props.class ?? ''}">
<div class="p-2">
<Slider bind:value={sliderValue} min={0} max={100} step={1} type="multiple" />
<Slider bind:value={sliderValue} min={0} max={100} step={1} type="single" />
</div>
<Label class="flex flex-row justify-between">
<span>{i18n._('toolbar.reduce.tolerance')}</span>
@@ -163,6 +163,9 @@ export class ReducedGPXLayerCollection {
reduce() {
let itemsAndPoints = new Map<ListItem, TrackPoint[]>();
this._simplified.forEach(([item, maxPts, points], itemFullId) => {
if (!get(selection).hasAnyParent(item)) {
return;
}
itemsAndPoints.set(
item,
points
@@ -21,7 +21,7 @@
SquareArrowUpLeft,
SquareArrowOutDownRight,
} from '@lucide/svelte';
import { brouterProfiles } from '$lib/components/toolbar/tools/routing/routing';
import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing';
import { i18n } from '$lib/i18n.svelte';
import { slide } from 'svelte/transition';
import {
@@ -167,7 +167,7 @@
{i18n._(`toolbar.routing.activities.${$routingProfile}`)}
</Select.Trigger>
<Select.Content>
{#each Object.keys(brouterProfiles) as profile}
{#each Object.keys(routingProfiles) as profile}
<Select.Item value={profile}
>{i18n._(
`toolbar.routing.activities.${profile}`
@@ -191,7 +191,7 @@
<ButtonWithTooltip
label={i18n._('toolbar.routing.reverse.tooltip')}
variant="outline"
class="gap-1 text-xs"
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
disabled={!validSelection}
onclick={fileActions.reverseSelection}
>
@@ -200,7 +200,7 @@
<ButtonWithTooltip
label={i18n._('toolbar.routing.route_back_to_start.tooltip')}
variant="outline"
class="gap-1 text-xs"
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
disabled={!validSelection}
onclick={() => {
const selected = selection.getOrderedSelection();
@@ -236,14 +236,14 @@
<ButtonWithTooltip
label={i18n._('toolbar.routing.round_trip.tooltip')}
variant="outline"
class="gap-1 text-xs"
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
disabled={!validSelection}
onclick={fileActions.createRoundTripForSelection}
>
<Repeat class="size-3" />{i18n._('toolbar.routing.round_trip.button')}
</ButtonWithTooltip>
</div>
<div class="w-full flex flex-row gap-2 items-end justify-between">
<div class="w-full flex flex-row gap-1 items-end justify-between">
<Help link={getURLForLanguage(i18n.lang, '/help/toolbar/routing')}>
{#if !validSelection}
{i18n._('toolbar.routing.help_no_file')}
File diff suppressed because it is too large Load Diff
@@ -6,37 +6,213 @@ import { get } from 'svelte/store';
const { routing, routingProfile, privateRoads } = settings;
export const brouterProfiles: { [key: string]: string } = {
bike: 'Trekking-dry',
racing_bike: 'fastbike',
gravel_bike: 'gravel',
mountain_bike: 'MTB',
foot: 'Hiking-Alpine-SAC6',
motorcycle: 'Car-FastEco',
water: 'river',
railway: 'rail',
export type RoutingProfile = {
engine: 'graphhopper' | 'brouter';
profile: string;
};
export const routingProfiles: { [key: string]: RoutingProfile } = {
bike: { engine: 'graphhopper', profile: 'bike' },
racing_bike: { engine: 'graphhopper', profile: 'racingbike' },
gravel_bike: { engine: 'graphhopper', profile: 'gravelbike' },
mountain_bike: { engine: 'graphhopper', profile: 'mtb' },
foot: { engine: 'graphhopper', profile: 'foot' },
motorcycle: { engine: 'graphhopper', profile: 'motorbike' },
water: { engine: 'brouter', profile: 'river' },
railway: { engine: 'brouter', profile: 'rail' },
};
export function route(points: Coordinates[]): Promise<TrackPoint[]> {
if (get(routing)) {
return getRoute(points, brouterProfiles[get(routingProfile)], get(privateRoads));
const profile = routingProfiles[get(routingProfile)];
if (profile.engine === 'graphhopper') {
return getGraphHopperRoute(points, profile.profile, get(privateRoads));
} else {
return getBRouterRoute(points, profile.profile);
}
} else {
return getIntermediatePoints(points);
}
}
async function getRoute(
const graphhopperDetails = ['road_class', 'surface', 'hike_rating', 'mtb_rating'];
const hikeRatingToSACScale: { [key: string]: string } = {
'1': 'hiking',
'2': 'mountain_hiking',
'3': 'demanding_mountain_hiking',
'4': 'alpine_hiking',
'5': 'demanding_alpine_hiking',
'6': 'difficult_alpine_hiking',
};
const mtbRatingToScale: { [key: string]: string } = {
'1': '0',
'2': '1',
'3': '2',
'4': '3',
'5': '4',
'6': '5',
'7': '6',
};
const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
bike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
racingbike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
gravelbike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
mtb: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
foot: {
priority: [
{
if: 'foot_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
motorcycle: {
priority: [
{
if: 'road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
};
async function getGraphHopperRoute(
points: Coordinates[],
brouterProfile: string,
graphHopperProfile: string,
privateRoads: boolean
): Promise<TrackPoint[]> {
let url = `https://brouter.gpx.studio?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile + (privateRoads ? '-private' : '')}&format=geojson&alternativeidx=0`;
let response = await fetch('https://graphhopper.gpx.studio/route', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
points: points.map((point) => [point.lon, point.lat]),
profile: graphHopperProfile,
elevation: true,
points_encoded: false,
details: graphhopperDetails,
custom_model: privateRoads
? {}
: graphhopperBlockPrivateCustomModels[graphHopperProfile] || {},
}),
});
if (!response.ok) {
const error = await response.json();
if (error.message.includes('Cannot find point 0')) {
throw new Error('toolbar.routing.error.from');
} else if (error.message.includes('Cannot find point 1')) {
if (points.length == 3) {
throw new Error('toolbar.routing.error.via');
} else {
throw new Error('toolbar.routing.error.to');
}
} else if (error.hints[0].details.includes('PointDistanceExceededException')) {
throw new Error('toolbar.routing.error.distance');
} else if (error.hints[0].details.includes('ConnectionNotFoundException')) {
throw new Error('toolbar.routing.error.connection');
} else {
throw new Error(error.message);
}
}
let json = await response.json();
let route: TrackPoint[] = [];
let coordinates = json.paths[0].points.coordinates;
let details = json.paths[0].details;
for (let i = 0; i < coordinates.length; i++) {
route.push(
new TrackPoint({
attributes: {
lat: coordinates[i][1],
lon: coordinates[i][0],
},
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
extensions: {},
})
);
}
for (let key of graphhopperDetails) {
let detail = details[key];
for (let i = 0; i < detail.length; i++) {
for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1); j++) {
if (detail[i][2] !== undefined && detail[i][2] !== 'missing') {
if (key === 'road_class') {
route[j].setExtension('highway', detail[i][2]);
} else if (key === 'hike_rating') {
const sacScale = hikeRatingToSACScale[detail[i][2]];
if (sacScale) {
route[j].setExtension('sac_scale', sacScale);
}
} else if (key === 'mtb_rating') {
const mtbScale = mtbRatingToScale[detail[i][2]];
if (mtbScale) {
route[j].setExtension('mtb_scale', mtbScale);
}
} else if (key === 'surface' && detail[i][2] !== 'other') {
route[j].setExtension('surface', detail[i][2]);
}
}
}
}
}
return route;
}
async function getBRouterRoute(
points: Coordinates[],
brouterProfile: string
): Promise<TrackPoint[]> {
let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
let response = await fetch(url);
// Check if the response is ok
if (!response.ok) {
throw new Error(`${await response.text()}`);
const error = await response.text();
if (error.includes('from-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.from');
} else if (error.includes('via1-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.via');
} else if (error.includes('to-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.to');
} else if (error.includes('Time-out')) {
throw new Error('toolbar.routing.error.timeout');
} else {
throw new Error(error);
}
}
let geojson = await response.json();
@@ -52,14 +228,13 @@ async function getRoute(
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
for (let i = 0; i < coordinates.length; i++) {
let coord = coordinates[i];
route.push(
new TrackPoint({
attributes: {
lat: coord[1],
lon: coord[0],
lat: coordinates[i][1],
lon: coordinates[i][0],
},
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0),
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
})
);
@@ -2,15 +2,21 @@ import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
const earthRadius = 6371008.8;
export const MIN_ANCHOR_ZOOM = 0;
export const MAX_ANCHOR_ZOOM = 22;
export function getZoomLevelForDistance(latitude: number, distance?: number): number {
if (distance === undefined) {
return 0;
return MIN_ANCHOR_ZOOM;
}
const rad = Math.PI / 180;
const lat = latitude * rad;
return Math.min(22, Math.max(0, Math.log2((earthRadius * Math.cos(lat)) / distance)));
return Math.min(
MAX_ANCHOR_ZOOM,
Math.max(MIN_ANCHOR_ZOOM, Math.round(Math.log2((earthRadius * Math.cos(lat)) / distance)))
);
}
export function updateAnchorPoints(file: GPXFile) {
@@ -36,7 +36,7 @@
onMount(() => {
if ($map) {
splitControls = new SplitControls($map);
splitControls = new SplitControls($map, map.layerEventManager!);
}
});
@@ -10,39 +10,31 @@ import { fileActions } from '$lib/logic/file-actions';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
export class SplitControls {
map: maplibregl.Map;
layerEventManager: MapLayerEventManager;
unsubscribes: Function[] = [];
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
constructor(map: maplibregl.Map) {
constructor(map: maplibregl.Map, layerEventManager: MapLayerEventManager) {
this.map = map;
if (!this.map.hasImage('split-control')) {
let icon = new Image(100, 100);
icon.onload = () => {
if (!this.map.hasImage('split-control')) {
this.map.addImage('split-control', icon);
}
};
// Lucide icons are SVG files with a 24x24 viewBox
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
icon.src =
'data:image/svg+xml,' +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="white" />
<g transform="translate(8 8)">
${Scissors.replace('stroke="currentColor"', 'stroke="black"')}
</g>
</svg>
`);
}
this.layerEventManager = layerEventManager;
loadSVGIcon(
this.map,
'split-control',
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="20" fill="white" />
<g transform="translate(8 8)">
${Scissors.replace('stroke="currentColor"', 'stroke="black"')}
</g>
</svg>`
);
this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
@@ -125,9 +117,17 @@ export class SplitControls {
ANCHOR_LAYER_KEY.interactions
);
this.map.on('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
this.map.on('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
this.map.on('click', 'split-controls', this.layerOnClickBinded);
this.layerEventManager.on(
'mouseenter',
'split-controls',
this.layerOnMouseEnterBinded
);
this.layerEventManager.on(
'mouseleave',
'split-controls',
this.layerOnMouseLeaveBinded
);
this.layerEventManager.on('click', 'split-controls', this.layerOnClickBinded);
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
@@ -135,9 +135,9 @@ export class SplitControls {
}
remove() {
this.map.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
this.map.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
this.map.off('click', 'split-controls', this.layerOnClickBinded);
this.layerEventManager.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
this.layerEventManager.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
this.layerEventManager.off('click', 'split-controls', this.layerOnClickBinded);
try {
if (this.map.getLayer('split-controls')) {
@@ -161,66 +161,73 @@
</script>
<div class="flex flex-col gap-3 w-full max-w-96 {props.class ?? ''}">
<fieldset class="flex flex-col gap-2">
<Label for="name">{i18n._('menu.metadata.name')}</Label>
<Input
bind:value={name}
id="name"
class="font-semibold h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
<Label for="description">{i18n._('menu.metadata.description')}</Label>
<Textarea
bind:value={description}
id="description"
disabled={!canCreate && !$selectedWaypoint}
class="min-h-8 h-8 py-1 px-3 text-sm"
/>
<Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label>
<Select.Root bind:value={sym} type="single">
<Select.Trigger
id="symbol"
size="sm"
class="w-full"
<fieldset class="flex flex-col gap-1.5">
<div class="flex flex-col gap-1">
<Label for="name">{i18n._('menu.metadata.name')}</Label>
<Input
bind:value={name}
id="name"
class="font-semibold"
disabled={!canCreate && !$selectedWaypoint}
>
<span class="flex flex-row gap-1.5 items-center">
{#if symbolKey}
{#if symbols[symbolKey].icon}
{@const Component = symbols[symbolKey].icon}
<Component size="14" />
{/if}
{i18n._(`gpx.symbol.${symbolKey}`)}
{:else}
{sym}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll">
{#each sortedSymbols as [key, symbol]}
<Select.Item value={symbol.value}>
<span>
{#if symbol.icon}
{@const Component = symbol.icon}
<Component size="14" class="inline-block align-sub" />
{:else}
<span class="w-4 inline-block"></span>
/>
</div>
<div class="flex flex-col gap-1">
<Label for="description">{i18n._('menu.metadata.description')}</Label>
<Textarea
bind:value={description}
id="description"
disabled={!canCreate && !$selectedWaypoint}
class="min-h-8 h-8 py-1 px-3 text-sm"
/>
</div>
<div class="flex flex-col gap-1">
<Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label>
<Select.Root bind:value={sym} type="single">
<Select.Trigger
id="symbol"
class="w-full"
disabled={!canCreate && !$selectedWaypoint}
>
<span class="flex flex-row gap-1.5 items-center">
{#if symbolKey}
{#if symbols[symbolKey].icon}
{@const Component = symbols[symbolKey].icon}
<Component size="14" />
{/if}
{i18n._(`gpx.symbol.${key}`)}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Label for="link">{i18n._('toolbar.waypoint.link')}</Label>
<Input
bind:value={link}
id="link"
class="h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
<div class="flex flex-row gap-2">
<div class="grow flex flex-col gap-2">
{i18n._(`gpx.symbol.${symbolKey}`)}
{:else}
{sym}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-60">
{#each sortedSymbols as [key, symbol]}
<Select.Item value={symbol.value}>
<span>
{#if symbol.icon}
{@const Component = symbol.icon}
<Component size="14" class="inline-block align-sub" />
{:else}
<span class="w-4 inline-block"></span>
{/if}
{i18n._(`gpx.symbol.${key}`)}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1">
<Label for="link">{i18n._('toolbar.waypoint.link')}</Label>
<Input
bind:value={link}
id="link"
class="h-8"
disabled={!canCreate && !$selectedWaypoint}
/>
</div>
<div class="flex flex-row gap-1.5">
<div class="grow flex flex-col gap-1">
<Label for="latitude">{i18n._('toolbar.waypoint.latitude')}</Label>
<Input
bind:value={latitude}
@@ -233,7 +240,7 @@
disabled={!canCreate && !$selectedWaypoint}
/>
</div>
<div class="grow flex flex-col gap-2">
<div class="grow flex flex-col gap-1">
<Label for="longitude">{i18n._('toolbar.waypoint.longitude')}</Label>
<Input
bind:value={longitude}
@@ -248,11 +255,11 @@
</div>
</div>
</fieldset>
<div class="flex flex-row gap-2 items-center">
<div class="flex flex-row gap-1.5 items-center">
<Button
variant="outline"
disabled={!canCreate && !$selectedWaypoint}
class="grow whitespace-normal h-fit"
class="grow shrink h-fit min-h-8 whitespace-normal py-1"
onclick={createOrUpdateWaypoint}
>
{#if $selectedWaypoint}
@@ -13,10 +13,15 @@
<AccordionPrimitive.Content
bind:ref
data-slot="accordion-content"
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
class="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden"
{...restProps}
>
<div class={cn("pb-4 pt-0", className)}>
<div
class={cn(
"pt-0 pb-2.5 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4",
className
)}
>
{@render children?.()}
</div>
</AccordionPrimitive.Content>
@@ -12,6 +12,6 @@
<AccordionPrimitive.Item
bind:ref
data-slot="accordion-item"
class={cn("border-b last:border-b-0", className)}
class={cn("not-last:border-b", className)}
{...restProps}
/>
@@ -1,7 +1,8 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
let {
ref = $bindable(null),
@@ -19,14 +20,13 @@
data-slot="accordion-trigger"
bind:ref
class={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium outline-none transition-all hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
"focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-lg py-2.5 text-left text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
/>
<ChevronDownIcon data-slot="accordion-trigger-icon" class="cn-accordion-trigger-icon pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" class="cn-accordion-trigger-icon pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
@@ -1,9 +1,11 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...restProps
}: AccordionPrimitive.RootProps = $props();
</script>
@@ -12,5 +14,6 @@
bind:ref
bind:value={value as never}
data-slot="accordion"
class={cn("cn-accordion flex w-full flex-col", className)}
{...restProps}
/>
@@ -1,18 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import {
buttonVariants,
type ButtonVariant,
type ButtonSize,
} from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
size = "default",
...restProps
}: AlertDialogPrimitive.ActionProps = $props();
}: AlertDialogPrimitive.ActionProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Action
bind:ref
data-slot="alert-dialog-action"
class={cn(buttonVariants(), className)}
class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-action", className)}
{...restProps}
/>
@@ -1,18 +1,27 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import {
buttonVariants,
type ButtonVariant,
type ButtonSize,
} from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "outline",
size = "default",
...restProps
}: AlertDialogPrimitive.CancelProps = $props();
}: AlertDialogPrimitive.CancelProps & {
variant?: ButtonVariant;
size?: ButtonSize;
} = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
data-slot="alert-dialog-cancel"
class={cn(buttonVariants({ variant: "outline" }), className)}
class={cn(buttonVariants({ variant, size }), "cn-alert-dialog-cancel", className)}
{...restProps}
/>
@@ -1,27 +1,32 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import AlertDialogPortal from "./alert-dialog-portal.svelte";
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
import { cn, type WithoutChild, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
size = "default",
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
size?: "default" | "sm";
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
} = $props();
</script>
<AlertDialogPrimitive.Portal {...portalProps}>
<AlertDialogPortal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
data-slot="alert-dialog-content"
data-size={size}
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-popover text-popover-foreground ring-foreground/10 gap-4 rounded-xl p-4 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className
)}
{...restProps}
/>
</AlertDialogPrimitive.Portal>
</AlertDialogPortal>
@@ -12,6 +12,6 @@
<AlertDialogPrimitive.Description
bind:ref
data-slot="alert-dialog-description"
class={cn("text-muted-foreground text-sm", className)}
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3", className)}
{...restProps}
/>
@@ -13,7 +13,10 @@
<div
bind:this={ref}
data-slot="alert-dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
class={cn(
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...restProps}
>
{@render children?.()}
@@ -13,7 +13,7 @@
<div
bind:this={ref}
data-slot="alert-dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
class={cn("grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", className)}
{...restProps}
>
{@render children?.()}
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-dialog-media"
class={cn("bg-muted mb-2 inline-flex size-10 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -12,9 +12,6 @@
<AlertDialogPrimitive.Overlay
bind:ref
data-slot="alert-dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
{...restProps}
/>
@@ -1,9 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
type $$Props = AlertDialogPrimitive.PortalProps;
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
</script>
<AlertDialogPrimitive.Portal {...$$restProps}>
<slot />
</AlertDialogPrimitive.Portal>
<AlertDialogPrimitive.Portal {...restProps} />
@@ -12,6 +12,6 @@
<AlertDialogPrimitive.Title
bind:ref
data-slot="alert-dialog-title"
class={cn("text-lg font-semibold", className)}
class={cn("text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
</script>
<AlertDialogPrimitive.Root bind:open {...restProps} />
@@ -1,4 +1,5 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import Root from "./alert-dialog.svelte";
import Portal from "./alert-dialog-portal.svelte";
import Trigger from "./alert-dialog-trigger.svelte";
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
@@ -8,9 +9,7 @@ import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
const Root = AlertDialogPrimitive.Root;
const Portal = AlertDialogPrimitive.Portal;
import Media from "./alert-dialog-media.svelte";
export {
Root,
@@ -24,6 +23,7 @@ export {
Overlay,
Content,
Description,
Media,
//
Root as AlertDialog,
Title as AlertDialogTitle,
@@ -36,4 +36,5 @@ export {
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
Media as AlertDialogMedia,
};
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-action"
class={cn("absolute top-2 right-2", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="alert-description"
class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
"text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
className
)}
{...restProps}
@@ -13,7 +13,10 @@
<div
bind:this={ref}
data-slot="alert-title"
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
class={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
className
)}
{...restProps}
>
{@render children?.()}
@@ -2,12 +2,11 @@
import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
base: "grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 group/alert relative w-full",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
destructive: "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
@@ -36,9 +35,9 @@
<div
bind:this={ref}
data-slot="alert"
role="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
</div>
@@ -1,14 +1,17 @@
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
import Action from "./alert-action.svelte";
export { alertVariants, type AlertVariant } from "./alert.svelte";
export {
Root,
Description,
Title,
Action,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
Action as AlertAction,
};
@@ -4,25 +4,25 @@
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
outline:
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
@@ -12,7 +12,7 @@
<CalendarPrimitive.Cell
bind:ref
class={cn(
"size-(--cell-size) relative p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-l-md [&:last-child[data-selected]_[data-bits-day]]:rounded-r-md",
"relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-s-(--cell-radius) [&:last-child[data-selected]_[data-bits-day]]:rounded-e-(--cell-radius)",
className
)}
{...restProps}
@@ -1,5 +1,4 @@
<script lang="ts">
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { Calendar as CalendarPrimitive } from "bits-ui";
@@ -13,18 +12,17 @@
<CalendarPrimitive.Day
bind:ref
class={cn(
buttonVariants({ variant: "ghost" }),
"size-(--cell-size) flex select-none flex-col items-center justify-center gap-1 whitespace-nowrap p-0 font-normal leading-none",
"flex size-(--cell-size) flex-col items-center justify-center gap-1 rounded-(--cell-radius) p-0 leading-none font-normal whitespace-nowrap select-none",
"[&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
"not-data-selected:hover:bg-accent/50 not-data-selected:hover:text-accent-foreground",
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
"data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:hover:text-foreground",
// Outside months
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
// Disabled
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
// Unavailable
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
// hover
"dark:hover:text-accent-foreground",
// focus
"focus:border-ring focus:ring-ring/50 focus:relative",
// inner spans
@@ -11,6 +11,6 @@
<CalendarPrimitive.Grid
bind:ref
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
class={cn("flex w-full border-collapse flex-col", className)}
{...restProps}
/>
@@ -12,7 +12,7 @@
<CalendarPrimitive.Header
bind:ref
class={cn(
"h-(--cell-size) flex w-full items-center justify-center gap-1.5 text-sm font-medium",
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
className
)}
{...restProps}
@@ -14,11 +14,15 @@
<span
class={cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
"has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]",
className
)}
>
<CalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
<CalendarPrimitive.MonthSelect
bind:ref
class="bg-background dark:bg-popover dark:text-popover-foreground absolute inset-0 opacity-0"
{...restProps}
>
{#snippet child({ props, monthItems, selectedMonthItem })}
<select {...props} {value} {onchange}>
{#each monthItems as monthItem (monthItem.value)}
@@ -33,7 +37,7 @@
{/each}
</select>
<span
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pl-2 pr-1 text-sm font-medium [&>svg]:size-3.5"
class="[&>svg]:text-muted-foreground flex h-(--cell-size) items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
aria-hidden="true"
>
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}

Some files were not shown because too many files have changed in this diff Show More