jekyll-gis-blogger 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +170 -0
- data/lib/jekyll-gis-blogger/hooks/table_wrapper.rb +23 -0
- data/lib/jekyll-gis-blogger/tags/datatable.rb +156 -0
- data/lib/jekyll-gis-blogger/tags/gpx_track.rb +209 -0
- data/lib/jekyll-gis-blogger/tags/lungkui.rb +171 -0
- data/lib/jekyll-gis-blogger/tags/map.rb +101 -0
- data/lib/jekyll-gis-blogger/tags/storymap.rb +131 -0
- data/lib/jekyll-gis-blogger/utils.rb +38 -0
- data/lib/jekyll-gis-blogger.rb +17 -0
- metadata +70 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bd212331e34487d24a2802da86fa54d28bffa6f26c5f0440f207af631c1ce903
|
|
4
|
+
data.tar.gz: 1dbdd9262bd39aca7ed6606e0dd3f173d6a749bd3d8e6fb51e4c5611d4301dee
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7e26717d20db332f2d524a5a9af47dff8ed36171b274224d915022655e958adc7f4d07b68ae639132beb3453590a049aa70e3326883f4e813844c948911e28c8
|
|
7
|
+
data.tar.gz: f6292967d9c18128a78040eede0df12f6b716135b71052a4eecb8513363ea0d70bfd57e4d7cfefa1056e60278710be8f1fff642d03590665ecf4b25f4b3d67fc
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lerry William Seling
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# jekyll-gis-blogger
|
|
2
|
+
|
|
3
|
+
Jekyll Liquid tags and hooks for GIS storytelling blogs — maps, GPX tracks,
|
|
4
|
+
StoryMapJS, lungkui.js scrollytelling, and sortable data tables. Zero runtime
|
|
5
|
+
dependencies beyond Jekyll.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Jekyll site's `Gemfile`:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "jekyll-gis-blogger"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then add to `_config.yml`:
|
|
16
|
+
|
|
17
|
+
```yaml
|
|
18
|
+
plugins:
|
|
19
|
+
- jekyll-gis-blogger
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Then `bundle install`.
|
|
23
|
+
|
|
24
|
+
## Available Tags
|
|
25
|
+
|
|
26
|
+
### `{% map %}` — Leaflet single-marker map
|
|
27
|
+
|
|
28
|
+
```liquid
|
|
29
|
+
{% map lat="1.55" lon="110.33" %}
|
|
30
|
+
{% map lat="1.55" lon="110.33" zoom="14" caption="Kuching, Sarawak" height="500px" %}
|
|
31
|
+
{% map lat="1.55" lon="110.33" marker="false" %}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
| Attribute | Default | Description |
|
|
35
|
+
|-----------|---------|-------------|
|
|
36
|
+
| `lat` | required | Latitude (decimal degrees) |
|
|
37
|
+
| `lon` | required | Longitude (decimal degrees) |
|
|
38
|
+
| `zoom` | `12` | Leaflet zoom level |
|
|
39
|
+
| `height` | `400px` | Map height (CSS) |
|
|
40
|
+
| `width` | `100%` | Map width (CSS) |
|
|
41
|
+
| `caption` | — | HTML `<figcaption>` below map |
|
|
42
|
+
| `basemap` | OSM | Tile URL template |
|
|
43
|
+
| `attribution` | OSM | Tile attribution HTML |
|
|
44
|
+
| `marker` | `true` | Set `"false"` to hide marker |
|
|
45
|
+
|
|
46
|
+
Leaflet CSS/JS loaded from CDN once per page.
|
|
47
|
+
|
|
48
|
+
### `{% storymap %}` — StoryMapJS embed
|
|
49
|
+
|
|
50
|
+
**GeoJSON (build-time transform):**
|
|
51
|
+
|
|
52
|
+
```liquid
|
|
53
|
+
{% storymap geojson="/assets/datasets/my_story.geojson" %}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**CSV (build-time transform):**
|
|
57
|
+
|
|
58
|
+
```liquid
|
|
59
|
+
{% storymap csv="/assets/datasets/my_story.csv" %}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Raw StoryMapJS JSON:**
|
|
63
|
+
|
|
64
|
+
```liquid
|
|
65
|
+
{% storymap data="/assets/datasets/my_story.json" %}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Options: `height="600px" width="80%" options='{"calculate_zoom":false}'`
|
|
69
|
+
|
|
70
|
+
#### Authoring schema
|
|
71
|
+
|
|
72
|
+
| Field | Description |
|
|
73
|
+
|-------|-------------|
|
|
74
|
+
| `headline` | Slide title |
|
|
75
|
+
| `text` | Slide body (HTML allowed) |
|
|
76
|
+
| `media` | Image/video/embed URL |
|
|
77
|
+
| `caption` | Media caption |
|
|
78
|
+
| `credit` | Media credit |
|
|
79
|
+
| `name` | Marker label |
|
|
80
|
+
| `zoom` | Zoom level (integer) |
|
|
81
|
+
| `icon` | Marker image URL |
|
|
82
|
+
| `line` | `true`/`1` to draw connecting line |
|
|
83
|
+
| `date` | Optional date |
|
|
84
|
+
| `order` | Slide order (integer, default: file order) |
|
|
85
|
+
| `overview` | `1`/`true` for intro/title slide |
|
|
86
|
+
| `lat`, `lon` | CSV only; GeoJSON uses Point geometry |
|
|
87
|
+
|
|
88
|
+
#### Required assets
|
|
89
|
+
|
|
90
|
+
- `assets/css/storymap.css`
|
|
91
|
+
- `assets/js/storymap-min.js`
|
|
92
|
+
|
|
93
|
+
### `{% lungkui %}` — lungkui.js MapLibre scrollytelling
|
|
94
|
+
|
|
95
|
+
**GeoJSON (build-time transform):**
|
|
96
|
+
|
|
97
|
+
```liquid
|
|
98
|
+
{% lungkui geojson="/assets/datasets/my_story.geojson" %}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Raw config JSON:**
|
|
102
|
+
|
|
103
|
+
```liquid
|
|
104
|
+
{% lungkui config="/assets/datasets/my_story.lungkui.json" %}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Options: `height="600px" width="100%" mode="deck"|"scroll" center="113.9,3.5" zoom="6" accent="#e4572e" basemap="..."`
|
|
108
|
+
|
|
109
|
+
Mode `deck` (default) uses arrow keys/click — no page scroll needed. Mode `scroll` drives the map from page scroll position.
|
|
110
|
+
|
|
111
|
+
#### Authoring schema
|
|
112
|
+
|
|
113
|
+
Same GeoJSON field names as storymap (`headline`, `text`, `media`, `caption`, `credit`, `zoom`, `order`, `overview`). Coordinates from Point geometry.
|
|
114
|
+
|
|
115
|
+
#### Required assets
|
|
116
|
+
|
|
117
|
+
- `assets/js/lungkui.js`
|
|
118
|
+
- `assets/css/lungkui.css`
|
|
119
|
+
- MapLibre GL JS loaded from CDN automatically
|
|
120
|
+
|
|
121
|
+
### `{% gpx_track %}` — GPX track + elevation profile
|
|
122
|
+
|
|
123
|
+
```liquid
|
|
124
|
+
{% gpx_track file="/assets/tracks/hike.gpx" %}
|
|
125
|
+
{% gpx_track file="/assets/tracks/hike.gpx" height="500px" color="#e4572e" caption="Day 1 survey" %}
|
|
126
|
+
{% gpx_track file="/assets/tracks/hike.gpx" profile="false" %}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
| Attribute | Default | Description |
|
|
130
|
+
|-----------|---------|-------------|
|
|
131
|
+
| `file` | required | Path to GPX file |
|
|
132
|
+
| `height` | `400px` | Map height (CSS) |
|
|
133
|
+
| `width` | `100%` | Map width (CSS) |
|
|
134
|
+
| `color` | `#e4572e` | Track line color |
|
|
135
|
+
| `caption` | — | HTML `<figcaption>` |
|
|
136
|
+
| `profile` | `true` | Set `"false"` to hide elevation SVG |
|
|
137
|
+
|
|
138
|
+
Renders: Leaflet map with track polyline + start/end markers, inline SVG elevation profile, and stats (distance, gain, loss, elevation range).
|
|
139
|
+
|
|
140
|
+
### `{% datatable %}` — Sortable table from GeoJSON/CSV
|
|
141
|
+
|
|
142
|
+
```liquid
|
|
143
|
+
{% datatable src="/assets/data/sites.geojson" %}
|
|
144
|
+
{% datatable src="/assets/data/samples.csv" caption="Field samples" %}
|
|
145
|
+
{% datatable src="/assets/data/sites.geojson" cols="name,date,elevation" sort="date" %}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
| Attribute | Default | Description |
|
|
149
|
+
|-----------|---------|-------------|
|
|
150
|
+
| `src` | required | Path to `.geojson` or `.csv` |
|
|
151
|
+
| `caption` | — | HTML `<caption>` |
|
|
152
|
+
| `cols` | all | Comma-separated columns to show |
|
|
153
|
+
| `sort` | first col | Column to pre-sort by |
|
|
154
|
+
|
|
155
|
+
Click column headers to sort ascending/descending. Client-side vanilla JS.
|
|
156
|
+
|
|
157
|
+
### Automatic: Responsive table wrapper
|
|
158
|
+
|
|
159
|
+
All `<table>` elements in posts/pages are automatically wrapped in
|
|
160
|
+
`<div class="table-wrapper">` for mobile scroll. Tables already inside
|
|
161
|
+
`.dt-scroll` or `.table-wrapper` are skipped.
|
|
162
|
+
|
|
163
|
+
## Requirements
|
|
164
|
+
|
|
165
|
+
- Jekyll ~> 4.0
|
|
166
|
+
- Ruby >= 2.7
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module GisBlogger
|
|
5
|
+
module Hooks
|
|
6
|
+
# Post-render hook that wraps every rendered <table> in a responsive
|
|
7
|
+
# scroll container so tables never overflow on mobile.
|
|
8
|
+
# Tables inside .dt-scroll or .table-wrapper are skipped.
|
|
9
|
+
module TableWrapper
|
|
10
|
+
Jekyll::Hooks.register [:posts, :pages, :documents], :post_render do |doc|
|
|
11
|
+
doc.output = doc.output.gsub(%r{<table[^>]*>.*?</table>}m) do |match|
|
|
12
|
+
before = $`[-100..] || ""
|
|
13
|
+
if before.include?("dt-scroll") || before.include?("table-wrapper")
|
|
14
|
+
match
|
|
15
|
+
else
|
|
16
|
+
"<div class=\"table-wrapper\">#{match}</div>"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "csv"
|
|
5
|
+
|
|
6
|
+
module Jekyll
|
|
7
|
+
module GisBlogger
|
|
8
|
+
module Tags
|
|
9
|
+
class DatatableTag < Liquid::Tag
|
|
10
|
+
include Jekyll::GisBlogger::Utils
|
|
11
|
+
|
|
12
|
+
def initialize(tag_name, markup, tokens)
|
|
13
|
+
super
|
|
14
|
+
@attrs = markup.scan(ATTR_RE).each_with_object({}) do |(k, dq, sq), h|
|
|
15
|
+
h[k] = dq || sq
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render(context)
|
|
20
|
+
site = context.registers[:site]
|
|
21
|
+
page = context.registers[:page]
|
|
22
|
+
|
|
23
|
+
src = @attrs["src"]
|
|
24
|
+
return warn_html("missing src=") unless present?(src)
|
|
25
|
+
|
|
26
|
+
raw = read_source(site, src)
|
|
27
|
+
return warn_html("#{src} is empty") if raw.empty?
|
|
28
|
+
|
|
29
|
+
if src.end_with?(".geojson")
|
|
30
|
+
headers, rows = from_geojson(raw)
|
|
31
|
+
else
|
|
32
|
+
headers, rows = from_csv(raw)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
return warn_html("#{src} has no data") if rows.empty?
|
|
36
|
+
|
|
37
|
+
cols = @attrs["cols"]&.split(",")&.map(&:strip)
|
|
38
|
+
sort_by = @attrs["sort"]
|
|
39
|
+
|
|
40
|
+
if cols
|
|
41
|
+
idxs = cols.map { |c| headers.index(c) }.compact
|
|
42
|
+
headers = cols.select { |c| headers.include?(c) }
|
|
43
|
+
rows = rows.map { |r| idxs.map { |i| r[i] } }
|
|
44
|
+
sort_by = nil unless headers.include?(sort_by)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
sort_idx = sort_by ? headers.index(sort_by) || 0 : 0
|
|
48
|
+
|
|
49
|
+
caption_html = present?(@attrs["caption"]) ? "<caption>#{@attrs['caption']}</caption>" : ""
|
|
50
|
+
|
|
51
|
+
count = page["datatable_count"] || 0
|
|
52
|
+
page["datatable_count"] = count + 1
|
|
53
|
+
table_id = "jekyll-dt-#{count}"
|
|
54
|
+
|
|
55
|
+
rows_html = (sort_idx ? sort_rows(rows, sort_idx) : rows).map do |row|
|
|
56
|
+
"<tr>" + row.map { |cell| "<td>#{escape_html(cell)}</td>" }.join + "</tr>"
|
|
57
|
+
end.join("\n")
|
|
58
|
+
|
|
59
|
+
thead = "<thead><tr>" + headers.map.with_index { |h, i|
|
|
60
|
+
cls = i == sort_idx ? " class=\"sorted-asc\"" : ""
|
|
61
|
+
"<th#{cls}>#{escape_html(h)}</th>"
|
|
62
|
+
}.join + "</tr></thead>"
|
|
63
|
+
|
|
64
|
+
assets = inject_sort_js_once(page)
|
|
65
|
+
|
|
66
|
+
[
|
|
67
|
+
assets,
|
|
68
|
+
"<figure class=\"jekyll-datatable\">",
|
|
69
|
+
" #{caption_html}",
|
|
70
|
+
" <div class=\"dt-scroll\">",
|
|
71
|
+
" <table id=\"#{table_id}\" class=\"dt-table\">",
|
|
72
|
+
" #{thead}",
|
|
73
|
+
" <tbody>",
|
|
74
|
+
" #{rows_html}",
|
|
75
|
+
" </tbody>",
|
|
76
|
+
" </table>",
|
|
77
|
+
" </div>",
|
|
78
|
+
"</figure>"
|
|
79
|
+
].join("\n")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def from_geojson(text)
|
|
85
|
+
features = JSON.parse(text)["features"] || []
|
|
86
|
+
return [[], []] if features.empty?
|
|
87
|
+
|
|
88
|
+
headers = features.first["properties"]&.keys || []
|
|
89
|
+
rows = features.map { |f| (f["properties"] || {}).values.map(&:to_s) }
|
|
90
|
+
[headers, rows]
|
|
91
|
+
rescue JSON::ParserError => e
|
|
92
|
+
Jekyll.logger.warn "datatable:", "Invalid GeoJSON: #{e.message}"
|
|
93
|
+
[[], []]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def from_csv(text)
|
|
97
|
+
table = CSV.parse(text, headers: true)
|
|
98
|
+
headers = table.headers || []
|
|
99
|
+
rows = table.map { |row| row.fields.map(&:to_s) }
|
|
100
|
+
[headers, rows]
|
|
101
|
+
rescue CSV::MalformedCSVError => e
|
|
102
|
+
Jekyll.logger.warn "datatable:", "Invalid CSV: #{e.message}"
|
|
103
|
+
[[], []]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def sort_rows(rows, col_idx)
|
|
107
|
+
rows.sort_by do |r|
|
|
108
|
+
val = r[col_idx].to_s
|
|
109
|
+
Float(val)
|
|
110
|
+
rescue ArgumentError
|
|
111
|
+
val.downcase
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def escape_html(text)
|
|
116
|
+
text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def inject_sort_js_once(page)
|
|
120
|
+
return "" if page["datatable_js_injected"]
|
|
121
|
+
|
|
122
|
+
page["datatable_js_injected"] = true
|
|
123
|
+
<<~JS
|
|
124
|
+
<script>
|
|
125
|
+
(function(){
|
|
126
|
+
if(window.__dtSortInit)return;window.__dtSortInit=true;
|
|
127
|
+
document.addEventListener('click',function(e){
|
|
128
|
+
var th=e.target.closest('.dt-table th');if(!th)return;
|
|
129
|
+
var t=th.closest('table'),tb=t.querySelector('tbody'),rows=Array.from(tb.rows);
|
|
130
|
+
var idx=Array.from(th.parentNode.children).indexOf(th);
|
|
131
|
+
var asc=!th.classList.contains('sorted-asc');
|
|
132
|
+
t.querySelectorAll('th').forEach(function(h){h.classList.remove('sorted-asc','sorted-desc')});
|
|
133
|
+
th.classList.add(asc?'sorted-asc':'sorted-desc');
|
|
134
|
+
var isNum=rows.length>0&&!isNaN(parseFloat(rows[0].cells[idx].textContent));
|
|
135
|
+
rows.sort(function(a,b){
|
|
136
|
+
var av=a.cells[idx].textContent.trim(),bv=b.cells[idx].textContent.trim();
|
|
137
|
+
if(isNum){return asc?av-bv:bv-av;}
|
|
138
|
+
return asc?av.localeCompare(bv):bv.localeCompare(av);
|
|
139
|
+
});
|
|
140
|
+
rows.forEach(function(r){tb.appendChild(r)});
|
|
141
|
+
});
|
|
142
|
+
})();
|
|
143
|
+
</script>
|
|
144
|
+
JS
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def warn_html(reason)
|
|
148
|
+
Jekyll.logger.warn "datatable:", reason
|
|
149
|
+
"<!-- datatable: #{reason} -->"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
Liquid::Template.register_tag("datatable", Jekyll::GisBlogger::Tags::DatatableTag)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rexml/document"
|
|
4
|
+
|
|
5
|
+
module Jekyll
|
|
6
|
+
module GisBlogger
|
|
7
|
+
module Tags
|
|
8
|
+
class GpxTrackTag < Liquid::Tag
|
|
9
|
+
include Jekyll::GisBlogger::Utils
|
|
10
|
+
|
|
11
|
+
DEFAULTS = {
|
|
12
|
+
"height" => "400px",
|
|
13
|
+
"width" => "100%",
|
|
14
|
+
"color" => "#e4572e"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
EARTH_R = 6_371_000.0
|
|
18
|
+
|
|
19
|
+
def initialize(tag_name, markup, tokens)
|
|
20
|
+
super
|
|
21
|
+
@attrs = markup.scan(ATTR_RE).each_with_object({}) do |(k, dq, sq), h|
|
|
22
|
+
h[k] = dq || sq
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def render(context)
|
|
27
|
+
site = context.registers[:site]
|
|
28
|
+
baseurl = context.registers[:site].config["baseurl"].to_s.chomp("/")
|
|
29
|
+
page = context.registers[:page]
|
|
30
|
+
|
|
31
|
+
file = @attrs["file"]
|
|
32
|
+
return warn_html("missing file=") unless present?(file)
|
|
33
|
+
|
|
34
|
+
gpx_xml = read_source(site, file)
|
|
35
|
+
return warn_html("#{file} is empty") if gpx_xml.empty?
|
|
36
|
+
|
|
37
|
+
points = parse_gpx(gpx_xml)
|
|
38
|
+
return warn_html("#{file} has no track points") if points.empty?
|
|
39
|
+
|
|
40
|
+
height = @attrs["height"] || DEFAULTS["height"]
|
|
41
|
+
width = @attrs["width"] || DEFAULTS["width"]
|
|
42
|
+
color = @attrs["color"] || DEFAULTS["color"]
|
|
43
|
+
caption = @attrs["caption"].to_s
|
|
44
|
+
show_profile = @attrs["profile"] != "false" && points.any? { |p| p[:ele] }
|
|
45
|
+
|
|
46
|
+
count = page["gpx_map_count"] || 0
|
|
47
|
+
page["gpx_map_count"] = count + 1
|
|
48
|
+
div_id = "gpx-map-#{count}"
|
|
49
|
+
prof_id = "gpx-profile-#{count}"
|
|
50
|
+
|
|
51
|
+
stats = compute_stats(points)
|
|
52
|
+
assets = inject_leaflet_once(page)
|
|
53
|
+
latlngs_js = points.map { |p| "[#{p[:lat]},#{p[:lon]}]" }.join(",")
|
|
54
|
+
|
|
55
|
+
caption_html = present?(caption) ? "<figcaption class=\"map-caption\">#{caption}</figcaption>" : ""
|
|
56
|
+
profile_html = show_profile ? profile_svg(prof_id, points, stats, color) : ""
|
|
57
|
+
|
|
58
|
+
[
|
|
59
|
+
assets,
|
|
60
|
+
"<figure class=\"jekyll-gpx\">",
|
|
61
|
+
" <div id=\"#{div_id}\" style=\"width: #{width}; height: #{height};\"></div>",
|
|
62
|
+
" #{profile_html}",
|
|
63
|
+
" #{stats_html(stats)}",
|
|
64
|
+
" #{caption_html}",
|
|
65
|
+
"</figure>",
|
|
66
|
+
"<script>",
|
|
67
|
+
"(function(){",
|
|
68
|
+
" var el=document.getElementById('#{div_id}');if(!el)return;",
|
|
69
|
+
" var track=[#{latlngs_js}];",
|
|
70
|
+
" var map=L.map(el).fitBounds(L.latLngBounds(track));",
|
|
71
|
+
" L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OSM contributors'}).addTo(map);",
|
|
72
|
+
" L.polyline(track,{color:'#{color}',weight:3,opacity:0.8}).addTo(map);",
|
|
73
|
+
" L.circleMarker(track[0],{radius:5,color:'#{color}',fillColor:'#fff',fillOpacity:1}).addTo(map);",
|
|
74
|
+
" L.circleMarker(track[track.length-1],{radius:5,color:'#{color}',fillColor:'#{color}',fillOpacity:1}).addTo(map);",
|
|
75
|
+
" window.addEventListener('resize',function(){map.invalidateSize();});",
|
|
76
|
+
"})();",
|
|
77
|
+
"</script>"
|
|
78
|
+
].join("\n")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def parse_gpx(xml)
|
|
84
|
+
doc = REXML::Document.new(xml)
|
|
85
|
+
points = []
|
|
86
|
+
doc.elements.each("//*") do |el|
|
|
87
|
+
next unless el.name == "trkpt"
|
|
88
|
+
|
|
89
|
+
lat = el.attributes["lat"]
|
|
90
|
+
lon = el.attributes["lon"]
|
|
91
|
+
next unless lat && lon
|
|
92
|
+
|
|
93
|
+
ele = el.elements["ele"] || el.elements["*[local-name()='ele']"]
|
|
94
|
+
points << { lat: lat.to_f, lon: lon.to_f, ele: ele&.text&.to_f }
|
|
95
|
+
break if points.size > 5000
|
|
96
|
+
end
|
|
97
|
+
points
|
|
98
|
+
rescue REXML::ParseException => e
|
|
99
|
+
Jekyll.logger.warn "gpx_track:", "Invalid GPX: #{e.message}"
|
|
100
|
+
[]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def compute_stats(points)
|
|
104
|
+
return {} if points.size < 2
|
|
105
|
+
|
|
106
|
+
dist = gain = loss = 0.0
|
|
107
|
+
min_e = max_e = points.first[:ele]
|
|
108
|
+
|
|
109
|
+
points.each_cons(2) do |a, b|
|
|
110
|
+
dist += haversine(a[:lat], a[:lon], b[:lat], b[:lon])
|
|
111
|
+
if a[:ele] && b[:ele]
|
|
112
|
+
diff = b[:ele] - a[:ele]
|
|
113
|
+
gain += diff if diff > 0
|
|
114
|
+
loss -= diff if diff < 0
|
|
115
|
+
min_e = b[:ele] if b[:ele] < min_e
|
|
116
|
+
max_e = b[:ele] if b[:ele] > max_e
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
min_e ||= 0; max_e ||= 0
|
|
121
|
+
|
|
122
|
+
{ distance_km: (dist / 1000.0).round(2),
|
|
123
|
+
gain_m: gain.round(0),
|
|
124
|
+
loss_m: loss.round(0),
|
|
125
|
+
min_elev: min_e.round(0),
|
|
126
|
+
max_elev: max_e.round(0) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def haversine(lat1, lon1, lat2, lon2)
|
|
130
|
+
dlat = (lat2 - lat1) * Math::PI / 180.0
|
|
131
|
+
dlon = (lon2 - lon1) * Math::PI / 180.0
|
|
132
|
+
a = Math.sin(dlat / 2)**2 +
|
|
133
|
+
Math.cos(lat1 * Math::PI / 180.0) * Math.cos(lat2 * Math::PI / 180.0) *
|
|
134
|
+
Math.sin(dlon / 2)**2
|
|
135
|
+
EARTH_R * 2.0 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def profile_svg(id, points, stats, color)
|
|
139
|
+
return "" if points.size < 2
|
|
140
|
+
|
|
141
|
+
w, h = 600, 150
|
|
142
|
+
pad = { top: 12, right: 8, bottom: 20, left: 44 }
|
|
143
|
+
pw = w - pad[:left] - pad[:right]
|
|
144
|
+
ph = h - pad[:top] - pad[:bottom]
|
|
145
|
+
|
|
146
|
+
elevs = points.map { |p| p[:ele] || 0.0 }
|
|
147
|
+
min_e = elevs.min
|
|
148
|
+
max_e = elevs.max
|
|
149
|
+
e_range = (max_e - min_e).nonzero? || 1.0
|
|
150
|
+
|
|
151
|
+
cum_dist = [0.0]
|
|
152
|
+
points.each_cons(2) { |a, b| cum_dist << cum_dist.last + haversine(a[:lat], a[:lon], b[:lat], b[:lon]) }
|
|
153
|
+
max_dist = cum_dist.last.nonzero? || 1.0
|
|
154
|
+
|
|
155
|
+
sx = ->(d) { pad[:left] + (d / max_dist) * pw }
|
|
156
|
+
sy = ->(e) { pad[:top] + (1.0 - (e - min_e) / e_range) * ph }
|
|
157
|
+
|
|
158
|
+
poly_pts = points.each_with_index.map { |p, i| "#{sx.call(cum_dist[i]).round(1)},#{sy.call(p[:ele] || 0.0).round(1)}" }.join(" ")
|
|
159
|
+
|
|
160
|
+
y_ticks = [min_e.round(-1), ((min_e + max_e) / 2).round(-1), max_e.round(-1)].uniq
|
|
161
|
+
y_labels = y_ticks.map { |v| "<text x=\"#{pad[:left] - 4}\" y=\"#{sy.call(v).round(1) + 4}\" text-anchor=\"end\" class=\"gpx-y-label\">#{v}m</text>" }.join
|
|
162
|
+
|
|
163
|
+
x_km = (max_dist / 1000.0).round(1)
|
|
164
|
+
x_label = "#{x_km} km"
|
|
165
|
+
|
|
166
|
+
%(<div class="gpx-profile" style="max-width:#{w}px;">
|
|
167
|
+
<svg id="#{id}" viewBox="0 0 #{w} #{h}" class="gpx-profile-svg" aria-label="Elevation profile: #{stats[:gain_m]}m gain, #{x_label}">
|
|
168
|
+
#{y_ticks.map { |v| "<line x1=\"#{pad[:left]}\" x2=\"#{w - pad[:right]}\" y1=\"#{sy.call(v).round(1)}\" y2=\"#{sy.call(v).round(1)}\" stroke=\"#ddd\" stroke-dasharray=\"3,3\"/>" }.join("\n ")}
|
|
169
|
+
<line x1="#{pad[:left]}" x2="#{w - pad[:right]}" y1="#{h - pad[:bottom]}" y2="#{h - pad[:bottom]}" stroke="#aaa"/>
|
|
170
|
+
<line x1="#{pad[:left]}" x2="#{pad[:left]}" y1="#{pad[:top]}" y2="#{h - pad[:bottom]}" stroke="#aaa"/>
|
|
171
|
+
<polygon points="#{sx.call(0).round(1)},#{h - pad[:bottom]} #{poly_pts} #{sx.call(max_dist).round(1)},#{h - pad[:bottom]}" fill="#{color}" opacity="0.12"/>
|
|
172
|
+
<polyline points="#{poly_pts}" fill="none" stroke="#{color}" stroke-width="2" vector-effect="non-scaling-stroke"/>
|
|
173
|
+
#{y_labels}
|
|
174
|
+
<text x="#{w - pad[:right]}" y="#{h - 4}" text-anchor="end" class="gpx-x-label">#{x_label}</text>
|
|
175
|
+
</svg>
|
|
176
|
+
</div>)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def stats_html(stats)
|
|
180
|
+
return "" if stats.empty?
|
|
181
|
+
|
|
182
|
+
"<dl class=\"gpx-stats\">" \
|
|
183
|
+
"<dt>Distance</dt><dd>#{stats[:distance_km]} km</dd>" \
|
|
184
|
+
"<dt>Gain</dt><dd>+#{stats[:gain_m]} m</dd>" \
|
|
185
|
+
"<dt>Loss</dt><dd>−#{stats[:loss_m]} m</dd>" \
|
|
186
|
+
"<dt>Elevation</dt><dd>#{stats[:min_elev]}–#{stats[:max_elev]} m</dd>" \
|
|
187
|
+
"</dl>"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def inject_leaflet_once(page)
|
|
191
|
+
return "" if page["leaflet_injected"]
|
|
192
|
+
|
|
193
|
+
page["leaflet_injected"] = true
|
|
194
|
+
[
|
|
195
|
+
%(<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="anonymous">),
|
|
196
|
+
%(<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin="anonymous"></script>)
|
|
197
|
+
].join("\n")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def warn_html(reason)
|
|
201
|
+
Jekyll.logger.warn "gpx_track:", reason
|
|
202
|
+
"<!-- gpx_track: #{reason} -->"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
Liquid::Template.register_tag("gpx_track", Jekyll::GisBlogger::Tags::GpxTrackTag)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Jekyll
|
|
6
|
+
module GisBlogger
|
|
7
|
+
module Tags
|
|
8
|
+
class LungkuiTag < Liquid::Tag
|
|
9
|
+
include Jekyll::GisBlogger::Utils
|
|
10
|
+
|
|
11
|
+
DEFAULTS = {
|
|
12
|
+
"height" => "600px",
|
|
13
|
+
"width" => "100%",
|
|
14
|
+
"mode" => "deck",
|
|
15
|
+
"zoom" => "6",
|
|
16
|
+
"accent" => "#e4572e",
|
|
17
|
+
"basemap" => "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
MAPLIBRE_CSS = "https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"
|
|
21
|
+
MAPLIBRE_JS = "https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"
|
|
22
|
+
|
|
23
|
+
IMAGE_RE = /\.(png|jpe?g|gif|webp|svg|avif)(\?|#|$)/i
|
|
24
|
+
|
|
25
|
+
def initialize(tag_name, markup, tokens)
|
|
26
|
+
super
|
|
27
|
+
@attrs = markup.scan(ATTR_RE).each_with_object({}) do |(k, dq, sq), h|
|
|
28
|
+
h[k] = dq || sq
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def render(context)
|
|
33
|
+
site = context.registers[:site]
|
|
34
|
+
baseurl = site.config["baseurl"].to_s.chomp("/")
|
|
35
|
+
page = context.registers[:page]
|
|
36
|
+
|
|
37
|
+
height = @attrs["height"] || DEFAULTS["height"]
|
|
38
|
+
width = @attrs["width"] || DEFAULTS["width"]
|
|
39
|
+
|
|
40
|
+
count = page["lungkui_map_count"] || 0
|
|
41
|
+
page["lungkui_map_count"] = count + 1
|
|
42
|
+
div_id = "lungkui-map-#{count}"
|
|
43
|
+
|
|
44
|
+
config_js = build_config_arg(site, baseurl)
|
|
45
|
+
assets = inject_assets_once(page, baseurl)
|
|
46
|
+
|
|
47
|
+
[
|
|
48
|
+
assets,
|
|
49
|
+
%(<div id="#{div_id}" class="lk-root lk-embed" ) +
|
|
50
|
+
%(style="position:relative;width:#{width};height:#{height};"></div>),
|
|
51
|
+
%(<script type="module">),
|
|
52
|
+
%(import { Lungkui } from '#{baseurl}/assets/js/lungkui.js';),
|
|
53
|
+
%(new Lungkui('##{div_id}', #{config_js});),
|
|
54
|
+
%(</script>)
|
|
55
|
+
].join("\n")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def build_config_arg(site, baseurl)
|
|
61
|
+
if @attrs["config"]
|
|
62
|
+
inline(parse_config(site, @attrs["config"]))
|
|
63
|
+
elsif @attrs["geojson"]
|
|
64
|
+
inline(build_config(site, baseurl))
|
|
65
|
+
else
|
|
66
|
+
raise ArgumentError, "{% lungkui %} needs one of: geojson= | config="
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_config(site, baseurl)
|
|
71
|
+
geojson_path = @attrs["geojson"]
|
|
72
|
+
slides = slides_from_geojson(read_source(site, geojson_path))
|
|
73
|
+
|
|
74
|
+
geojson_url = absolute?(geojson_path) ? geojson_path : "#{baseurl}#{geojson_path}"
|
|
75
|
+
center = @attrs["center"] ? @attrs["center"].split(",").map(&:to_f) : default_center(slides)
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
"mode" => @attrs["mode"] || DEFAULTS["mode"],
|
|
79
|
+
"map" => {
|
|
80
|
+
"basemap" => { "type" => "xyz", "url" => (@attrs["basemap"] || DEFAULTS["basemap"]),
|
|
81
|
+
"attribution" => "© OpenStreetMap contributors" },
|
|
82
|
+
"center" => center,
|
|
83
|
+
"zoom" => (@attrs["zoom"] || DEFAULTS["zoom"]).to_f
|
|
84
|
+
},
|
|
85
|
+
"theme" => { "accent" => (@attrs["accent"] || DEFAULTS["accent"]) },
|
|
86
|
+
"layers" => [{
|
|
87
|
+
"id" => "trail", "type" => "circle",
|
|
88
|
+
"source" => { "geojson" => geojson_url },
|
|
89
|
+
"paint" => { "circle-radius" => 7, "circle-color" => "@accent",
|
|
90
|
+
"circle-stroke-color" => "#fff", "circle-stroke-width" => 2 }
|
|
91
|
+
}],
|
|
92
|
+
"slides" => slides
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def slides_from_geojson(text)
|
|
97
|
+
features = JSON.parse(text)["features"] || []
|
|
98
|
+
indexed = features.each_with_index.map do |f, i|
|
|
99
|
+
props = f["properties"] || {}
|
|
100
|
+
coords = (f["geometry"] || {})["coordinates"] || []
|
|
101
|
+
[props, coords[0], coords[1], order_of(props, i), i]
|
|
102
|
+
end
|
|
103
|
+
ordered = indexed.sort_by { |props, _lon, _lat, order, i| [overview?(props) ? 0 : 1, order, i] }
|
|
104
|
+
ordered.each_with_index.map { |(props, lon, lat, _o, _i), pos| build_slide(props, lon, lat, pos) }
|
|
105
|
+
rescue JSON::ParserError => e
|
|
106
|
+
Jekyll.logger.warn "lungkui:", "Invalid GeoJSON: #{e.message}"
|
|
107
|
+
[]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def build_slide(props, lon, lat, pos)
|
|
111
|
+
html = props["text"].to_s
|
|
112
|
+
media = props["media"].to_s
|
|
113
|
+
media_block = nil
|
|
114
|
+
if present?(media)
|
|
115
|
+
if media.match?(IMAGE_RE)
|
|
116
|
+
media_block = { "url" => media, "caption" => props["caption"].to_s, "credit" => props["credit"].to_s }
|
|
117
|
+
else
|
|
118
|
+
html += %(<p><a href="#{media}" target="_blank" rel="noopener">Read more →</a></p>)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
overview = overview?(props)
|
|
123
|
+
zoom = if overview
|
|
124
|
+
(@attrs["zoom"] || DEFAULTS["zoom"]).to_f
|
|
125
|
+
else
|
|
126
|
+
present?(props["zoom"]) ? props["zoom"].to_f : 9
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
slide = {
|
|
130
|
+
"id" => "s#{pos}",
|
|
131
|
+
"position" => overview ? "full" : (pos.odd? ? "left" : "right"),
|
|
132
|
+
"title" => props["headline"].to_s,
|
|
133
|
+
"html" => html,
|
|
134
|
+
"camera" => { "center" => [lon, lat], "zoom" => zoom, "pitch" => overview ? 0 : 40 },
|
|
135
|
+
"show" => ["trail"]
|
|
136
|
+
}
|
|
137
|
+
slide["media"] = media_block if media_block
|
|
138
|
+
slide
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def default_center(slides)
|
|
142
|
+
(slides.find { |s| s["position"] == "full" } || slides.first)&.dig("camera", "center") || [0, 0]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def parse_config(site, path)
|
|
146
|
+
JSON.parse(read_source(site, path))
|
|
147
|
+
rescue JSON::ParserError => e
|
|
148
|
+
Jekyll.logger.warn "lungkui:", "Invalid config JSON: #{e.message}"
|
|
149
|
+
{}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def overview?(props)
|
|
153
|
+
truthy(props["overview"]) || props["type"].to_s == "overview"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def inject_assets_once(page, baseurl)
|
|
157
|
+
return "" if page["lungkui_assets_injected"]
|
|
158
|
+
|
|
159
|
+
page["lungkui_assets_injected"] = true
|
|
160
|
+
[
|
|
161
|
+
%(<link rel="stylesheet" href="#{MAPLIBRE_CSS}">),
|
|
162
|
+
%(<link rel="stylesheet" href="#{baseurl}/assets/css/lungkui.css">),
|
|
163
|
+
%(<script src="#{MAPLIBRE_JS}"></script>)
|
|
164
|
+
].join("\n")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
Liquid::Template.register_tag("lungkui", Jekyll::GisBlogger::Tags::LungkuiTag)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module GisBlogger
|
|
5
|
+
module Tags
|
|
6
|
+
class MapTag < Liquid::Tag
|
|
7
|
+
include Jekyll::GisBlogger::Utils
|
|
8
|
+
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
"height" => "400px",
|
|
11
|
+
"width" => "100%",
|
|
12
|
+
"zoom" => "12",
|
|
13
|
+
"basemap" => "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
14
|
+
"attribution" => '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
LEAFLET_CSS = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
18
|
+
LEAFLET_JS = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
19
|
+
|
|
20
|
+
def initialize(tag_name, markup, tokens)
|
|
21
|
+
super
|
|
22
|
+
@attrs = markup.scan(ATTR_RE).each_with_object({}) do |(k, dq, sq), h|
|
|
23
|
+
h[k] = dq || sq
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def render(context)
|
|
28
|
+
page = context.registers[:page]
|
|
29
|
+
baseurl = context.registers[:site].config["baseurl"].to_s.chomp("/")
|
|
30
|
+
|
|
31
|
+
unless present?(@attrs["lat"]) && present?(@attrs["lon"])
|
|
32
|
+
Jekyll.logger.warn "map:", "Missing lat= or lon= — tag ignored"
|
|
33
|
+
return "<!-- map tag: missing lat= or lon= -->"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
lat = parse_float("lat") or return warn_html("lat")
|
|
37
|
+
lon = parse_float("lon") or return warn_html("lon")
|
|
38
|
+
zoom = Integer(@attrs["zoom"] || DEFAULTS["zoom"])
|
|
39
|
+
height = @attrs["height"] || DEFAULTS["height"]
|
|
40
|
+
width = @attrs["width"] || DEFAULTS["width"]
|
|
41
|
+
caption = @attrs["caption"].to_s
|
|
42
|
+
marker = @attrs["marker"] != "false"
|
|
43
|
+
|
|
44
|
+
count = page["map_count"] || 0
|
|
45
|
+
page["map_count"] = count + 1
|
|
46
|
+
div_id = "jekyll-map-#{count}"
|
|
47
|
+
|
|
48
|
+
assets = inject_leaflet_once(page)
|
|
49
|
+
caption_html = present?(caption) ? "<figcaption class=\"map-caption\">#{caption}</figcaption>" : ""
|
|
50
|
+
marker_js = marker ? "L.marker([#{lat}, #{lon}]).addTo(map).bindPopup('#{escape_js(caption)}');" : ""
|
|
51
|
+
|
|
52
|
+
[
|
|
53
|
+
assets,
|
|
54
|
+
"<figure class=\"jekyll-map\">",
|
|
55
|
+
" <div id=\"#{div_id}\" style=\"width: #{width}; height: #{height};\"></div>",
|
|
56
|
+
" #{caption_html}",
|
|
57
|
+
"</figure>",
|
|
58
|
+
"<script>",
|
|
59
|
+
"(function(){",
|
|
60
|
+
" var el=document.getElementById('#{div_id}');if(!el)return;",
|
|
61
|
+
" var map=L.map(el).setView([#{lat},#{lon}],#{zoom});",
|
|
62
|
+
" L.tileLayer('#{@attrs["basemap"] || DEFAULTS["basemap"]}',{attribution:'#{escape_js(@attrs["attribution"] || DEFAULTS["attribution"])}'}).addTo(map);",
|
|
63
|
+
" #{marker_js}",
|
|
64
|
+
" window.addEventListener('resize',function(){map.invalidateSize();});",
|
|
65
|
+
"})();",
|
|
66
|
+
"</script>"
|
|
67
|
+
].join("\n")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def parse_float(name)
|
|
73
|
+
Float(@attrs[name])
|
|
74
|
+
rescue ArgumentError
|
|
75
|
+
Jekyll.logger.warn "map:", "#{name} must be a number, got: #{@attrs[name].inspect}"
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def warn_html(name)
|
|
80
|
+
"<!-- map tag: invalid #{name}= -->"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def inject_leaflet_once(page)
|
|
84
|
+
return "" if page["leaflet_injected"]
|
|
85
|
+
|
|
86
|
+
page["leaflet_injected"] = true
|
|
87
|
+
[
|
|
88
|
+
%(<link rel="stylesheet" href="#{LEAFLET_CSS}" crossorigin="anonymous">),
|
|
89
|
+
%(<script src="#{LEAFLET_JS}" crossorigin="anonymous"></script>)
|
|
90
|
+
].join("\n")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def escape_js(text)
|
|
94
|
+
text.to_s.gsub("\\", "\\\\").gsub("'", "\\'").gsub("\n", " ")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
Liquid::Template.register_tag("map", Jekyll::GisBlogger::Tags::MapTag)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "csv"
|
|
5
|
+
|
|
6
|
+
module Jekyll
|
|
7
|
+
module GisBlogger
|
|
8
|
+
module Tags
|
|
9
|
+
class StorymapTag < Liquid::Tag
|
|
10
|
+
include Jekyll::GisBlogger::Utils
|
|
11
|
+
|
|
12
|
+
DEFAULTS = { "height" => "480px", "width" => "100%", "options" => "{}" }.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(tag_name, markup, tokens)
|
|
15
|
+
super
|
|
16
|
+
@attrs = markup.scan(ATTR_RE).each_with_object({}) do |(k, dq, sq), h|
|
|
17
|
+
h[k] = dq || sq
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def render(context)
|
|
22
|
+
site = context.registers[:site]
|
|
23
|
+
baseurl = site.config["baseurl"].to_s.chomp("/")
|
|
24
|
+
page = context.registers[:page]
|
|
25
|
+
|
|
26
|
+
height = @attrs["height"] || DEFAULTS["height"]
|
|
27
|
+
width = @attrs["width"] || DEFAULTS["width"]
|
|
28
|
+
options = @attrs["options"] || DEFAULTS["options"]
|
|
29
|
+
|
|
30
|
+
count = page["storymap_map_count"] || 0
|
|
31
|
+
page["storymap_map_count"] = count + 1
|
|
32
|
+
div_id = "storymap-map-#{count}"
|
|
33
|
+
js_var = "storymapMap#{count}"
|
|
34
|
+
|
|
35
|
+
data_arg = build_data_arg(site, baseurl)
|
|
36
|
+
assets = inject_assets_once(page, baseurl)
|
|
37
|
+
|
|
38
|
+
[
|
|
39
|
+
assets,
|
|
40
|
+
"<div id=\"#{div_id}\" style=\"width: #{width}; height: #{height};\"></div>",
|
|
41
|
+
"<script>",
|
|
42
|
+
"var #{js_var} = new VCO.StoryMap('#{div_id}', #{data_arg}, #{options});",
|
|
43
|
+
"window.addEventListener('resize', function(){ #{js_var}.updateDisplay(); });",
|
|
44
|
+
"</script>"
|
|
45
|
+
].join("\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def build_data_arg(site, baseurl)
|
|
51
|
+
if @attrs["geojson"]
|
|
52
|
+
inline(build_object(slides_from_geojson(read_source(site, @attrs["geojson"]))))
|
|
53
|
+
elsif @attrs["csv"]
|
|
54
|
+
inline(build_object(slides_from_csv(read_source(site, @attrs["csv"]))))
|
|
55
|
+
elsif @attrs["data"]
|
|
56
|
+
url = absolute?(@attrs["data"]) ? @attrs["data"] : "#{baseurl}#{@attrs['data']}"
|
|
57
|
+
"'#{url}'"
|
|
58
|
+
else
|
|
59
|
+
raise ArgumentError, "{% storymap %} needs one of: data= | geojson= | csv="
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_object(slides)
|
|
64
|
+
{ "storymap" => { "slides" => slides } }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def slides_from_geojson(text)
|
|
68
|
+
features = JSON.parse(text)["features"] || []
|
|
69
|
+
indexed = features.each_with_index.map do |f, i|
|
|
70
|
+
props = f["properties"] || {}
|
|
71
|
+
coords = (f["geometry"] || {})["coordinates"] || []
|
|
72
|
+
[build_slide(props, coords[1], coords[0]), order_of(props, i), i]
|
|
73
|
+
end
|
|
74
|
+
sort_slides(indexed)
|
|
75
|
+
rescue JSON::ParserError => e
|
|
76
|
+
Jekyll.logger.warn "storymap:", "Invalid GeoJSON: #{e.message}"
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def slides_from_csv(text)
|
|
81
|
+
indexed = CSV.parse(text, headers: true).each_with_index.map do |row, i|
|
|
82
|
+
props = row.to_h
|
|
83
|
+
lat = (props["lat"] || props["latitude"]).to_f
|
|
84
|
+
lon = (props["lon"] || props["lng"] || props["longitude"]).to_f
|
|
85
|
+
[build_slide(props, lat, lon), order_of(props, i), i]
|
|
86
|
+
end
|
|
87
|
+
sort_slides(indexed)
|
|
88
|
+
rescue CSV::MalformedCSVError => e
|
|
89
|
+
Jekyll.logger.warn "storymap:", "Invalid CSV: #{e.message}"
|
|
90
|
+
[]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def build_slide(props, lat, lon)
|
|
94
|
+
text = { "headline" => props["headline"].to_s, "text" => props["text"].to_s }
|
|
95
|
+
media = { "url" => props["media"].to_s, "caption" => props["caption"].to_s, "credit" => props["credit"].to_s }
|
|
96
|
+
|
|
97
|
+
if truthy(props["overview"]) || props["type"].to_s == "overview"
|
|
98
|
+
slide = { "type" => "overview", "text" => text, "media" => media }
|
|
99
|
+
else
|
|
100
|
+
slide = { "location" => { "lat" => lat, "lon" => lon } }
|
|
101
|
+
slide["name"] = props["name"].to_s if present?(props["name"])
|
|
102
|
+
slide["zoom"] = props["zoom"].to_i if present?(props["zoom"])
|
|
103
|
+
slide["icon"] = props["icon"].to_s if present?(props["icon"])
|
|
104
|
+
slide["line"] = truthy(props["line"]) if present?(props["line"])
|
|
105
|
+
slide["text"] = text
|
|
106
|
+
slide["media"] = media
|
|
107
|
+
end
|
|
108
|
+
slide["date"] = props["date"].to_s if present?(props["date"])
|
|
109
|
+
slide
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def sort_slides(indexed)
|
|
113
|
+
indexed.sort_by { |slide, order, i| [slide["type"] == "overview" ? 0 : 1, order, i] }
|
|
114
|
+
.map { |slide, _order, _i| slide }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def inject_assets_once(page, baseurl)
|
|
118
|
+
return "" if page["storymap_assets_injected"]
|
|
119
|
+
|
|
120
|
+
page["storymap_assets_injected"] = true
|
|
121
|
+
[
|
|
122
|
+
"<link rel=\"stylesheet\" href=\"#{baseurl}/assets/css/storymap.css\">",
|
|
123
|
+
"<script type=\"text/javascript\" src=\"#{baseurl}/assets/js/storymap-min.js\"></script>"
|
|
124
|
+
].join("\n")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
Liquid::Template.register_tag("storymap", Jekyll::GisBlogger::Tags::StorymapTag)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module GisBlogger
|
|
5
|
+
# Shared helpers for all GIS Blogger Liquid tag plugins.
|
|
6
|
+
module Utils
|
|
7
|
+
ATTR_RE = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/
|
|
8
|
+
|
|
9
|
+
def read_source(site, path)
|
|
10
|
+
File.read(site.in_source_dir(path))
|
|
11
|
+
rescue StandardError => e
|
|
12
|
+
Jekyll.logger.warn "GisBlogger:", "Cannot read #{path}: #{e.message}"
|
|
13
|
+
""
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def inline(object)
|
|
17
|
+
JSON.generate(object).gsub("</", '<\/')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def absolute?(path)
|
|
21
|
+
path.start_with?("http://", "https://", "//")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def order_of(props, index)
|
|
25
|
+
present?(props["order"]) ? props["order"].to_f : index.to_f
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def truthy(v)
|
|
29
|
+
return v if v == true || v == false
|
|
30
|
+
%w[1 true yes y].include?(v.to_s.strip.downcase)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def present?(v)
|
|
34
|
+
!v.nil? && v.to_s.strip != ""
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jekyll"
|
|
4
|
+
|
|
5
|
+
module Jekyll
|
|
6
|
+
module GisBlogger
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
require_relative "jekyll-gis-blogger/utils"
|
|
11
|
+
|
|
12
|
+
require_relative "jekyll-gis-blogger/tags/storymap"
|
|
13
|
+
require_relative "jekyll-gis-blogger/tags/lungkui"
|
|
14
|
+
require_relative "jekyll-gis-blogger/tags/map"
|
|
15
|
+
require_relative "jekyll-gis-blogger/tags/gpx_track"
|
|
16
|
+
require_relative "jekyll-gis-blogger/tags/datatable"
|
|
17
|
+
require_relative "jekyll-gis-blogger/hooks/table_wrapper"
|
metadata
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: jekyll-gis-blogger
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Lerry William Seling
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: jekyll
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '4.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '4.0'
|
|
26
|
+
description: 'A collection of Jekyll Liquid tags and hooks for GIS blogging: Leaflet
|
|
27
|
+
maps, GPX track display with elevation profiles, StoryMapJS and lungkui.js story-map
|
|
28
|
+
embeds, and sortable HTML tables from GeoJSON/CSV. Zero runtime dependencies beyond
|
|
29
|
+
Jekyll.'
|
|
30
|
+
email:
|
|
31
|
+
- wslerry@gmail.com
|
|
32
|
+
executables: []
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- LICENSE
|
|
37
|
+
- README.md
|
|
38
|
+
- lib/jekyll-gis-blogger.rb
|
|
39
|
+
- lib/jekyll-gis-blogger/hooks/table_wrapper.rb
|
|
40
|
+
- lib/jekyll-gis-blogger/tags/datatable.rb
|
|
41
|
+
- lib/jekyll-gis-blogger/tags/gpx_track.rb
|
|
42
|
+
- lib/jekyll-gis-blogger/tags/lungkui.rb
|
|
43
|
+
- lib/jekyll-gis-blogger/tags/map.rb
|
|
44
|
+
- lib/jekyll-gis-blogger/tags/storymap.rb
|
|
45
|
+
- lib/jekyll-gis-blogger/utils.rb
|
|
46
|
+
homepage: https://github.com/wslerry/jekyll-gis-blogger
|
|
47
|
+
licenses:
|
|
48
|
+
- MIT
|
|
49
|
+
metadata:
|
|
50
|
+
homepage_uri: https://github.com/wslerry/jekyll-gis-blogger
|
|
51
|
+
source_code_uri: https://github.com/wslerry/jekyll-gis-blogger
|
|
52
|
+
rdoc_options: []
|
|
53
|
+
require_paths:
|
|
54
|
+
- lib
|
|
55
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: 2.7.0
|
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '0'
|
|
65
|
+
requirements: []
|
|
66
|
+
rubygems_version: 4.0.15
|
|
67
|
+
specification_version: 4
|
|
68
|
+
summary: Jekyll Liquid tags for GIS storytelling — maps, GPX tracks, StoryMapJS, lungkui.js,
|
|
69
|
+
and sortable data tables
|
|
70
|
+
test_files: []
|