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 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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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:'&copy; 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" => '&copy; <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: []