feat: expand trip inline stats to 6 stats + add cycling panel with GPX parsing
- Expanded stats block from 4 to 6 stats (days, entries, countries, cities, distance, temp range) - Added date_end-aware days calculation (uses header.date_end when available) - Added cities dedup logic (seen_city_lower) matching Task 1 pattern - Added temperature range computation (temp_min / temp_max) - Added has_gpx boolean flag - Distance label is conditional: km cycled (GPX) vs km roamed (no GPX) - Stats note text is conditional to match distance mode - Cycling button added to filter bar (only rendered when has_gpx) - Cycling panel (7 stat blocks) added after stats block (hidden by default, toggled independently) - Replaced old haversine IIFE with unified haversineKm + parseGpxFiles + IIFE - GPX Mode A: fetches GPX files, sums trackpoint distances, populates cycling panel - GPX Mode B: haversine between entry GPS points (no GPX) - Updated .trip-stats-grid from repeat(4) to repeat(3) columns - Added .trip-cycling-block, .trip-cycling-header, .trip-cycling-icon, .trip-cycling-title, .trip-cycling-grid CSS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WPJztrVGbwic2xTG7G9fjM
This commit is contained in:
@@ -990,7 +990,7 @@ body::after {
|
||||
|
||||
.trip-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
@@ -1010,6 +1010,43 @@ body::after {
|
||||
color: var(--color-ink-muted);
|
||||
}
|
||||
|
||||
/* ── Trip page cycling panel ─────────────────────────────────────────────────── */
|
||||
|
||||
.trip-cycling-block {
|
||||
background: var(--color-canvas);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-6);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.trip-cycling-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.trip-cycling-icon {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.trip-cycling-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.trip-cycling-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.trip-cycling-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* ── Story pages ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Override site-main constraints for story pages */
|
||||
|
||||
@@ -20,18 +20,21 @@
|
||||
|
||||
{# Stats computation #}
|
||||
{% set days_on_road = 0 %}
|
||||
{% set first_ts = null %}
|
||||
{% for entry in journal_entries %}
|
||||
{% set ts = entry.date|date('U') %}
|
||||
{% if first_ts is null or ts < first_ts %}
|
||||
{% set first_ts = ts %}
|
||||
{% if page.header.date_end is not empty %}
|
||||
{% set start_ts = page.header.date_start|date('U') %}
|
||||
{% set end_ts = page.header.date_end|date('U') %}
|
||||
{% set days_on_road = ((end_ts - start_ts) / 86400)|round(0, 'ceil') %}
|
||||
{% else %}
|
||||
{% set first_ts = null %}
|
||||
{% for entry in journal_entries %}
|
||||
{% set ts = entry.date|date('U') %}
|
||||
{% if first_ts is null or ts < first_ts %}{% set first_ts = ts %}{% endif %}
|
||||
{% endfor %}
|
||||
{% if first_ts is not null %}
|
||||
{% set diff_seconds = "now"|date('U') - first_ts %}
|
||||
{% set days_raw = (diff_seconds / 86400)|round(0, 'floor') %}
|
||||
{% set days_on_road = days_raw < 1 ? 1 : days_raw %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if first_ts is not null %}
|
||||
{% set now_ts = "now"|date('U') %}
|
||||
{% set diff_seconds = now_ts - first_ts %}
|
||||
{% set days_raw = (diff_seconds / 86400)|round(0, 'floor') %}
|
||||
{% set days_on_road = days_raw < 1 ? 1 : days_raw %}
|
||||
{% endif %}
|
||||
|
||||
{% set seen_lower = [] %}
|
||||
@@ -46,6 +49,28 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% set seen_city_lower = [] %}
|
||||
{% set city_display = [] %}
|
||||
{% for entry in journal_entries %}
|
||||
{% if entry.header.location_city is not empty %}
|
||||
{% set lower = entry.header.location_city|trim|lower %}
|
||||
{% if lower not in seen_city_lower %}
|
||||
{% set seen_city_lower = seen_city_lower|merge([lower]) %}
|
||||
{% set city_display = city_display|merge([entry.header.location_city|trim]) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% set temp_min = null %}
|
||||
{% set temp_max = null %}
|
||||
{% for entry in journal_entries %}
|
||||
{% if entry.header.weather_temp_c is defined and entry.header.weather_temp_c is not empty %}
|
||||
{% set t = entry.header.weather_temp_c %}
|
||||
{% if temp_min is null or t < temp_min %}{% set temp_min = t %}{% endif %}
|
||||
{% if temp_max is null or t > temp_max %}{% set temp_max = t %}{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% set gps_points = [] %}
|
||||
{% for entry in journal_entries %}
|
||||
{% if entry.header.lat is not empty and entry.header.lng is not empty %}
|
||||
@@ -59,6 +84,7 @@
|
||||
{% set gpx_urls = gpx_urls|merge([page.url ~ '/' ~ name]) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% set has_gpx = gpx_urls|length > 0 %}
|
||||
|
||||
{% set map_entries = [] %}
|
||||
{% for item in all_items %}
|
||||
@@ -97,7 +123,12 @@
|
||||
<button class="trip-filter-btn" data-filter="journal">Journal</button>
|
||||
<button class="trip-filter-btn" data-filter="story">Stories</button>
|
||||
</div>
|
||||
<button class="trip-stats-btn" id="trip-stats-toggle">Stats</button>
|
||||
<div class="trip-filter-group">
|
||||
<button class="trip-stats-btn" id="trip-stats-toggle">Stats</button>
|
||||
{% if has_gpx %}
|
||||
<button class="trip-stats-btn" id="trip-cycling-toggle">Cycling</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,17 +146,68 @@
|
||||
<span class="stat-value">{{ country_display|length }}</span>
|
||||
<span class="stat-label">{{ country_display|length == 1 ? 'country' : 'countries' }} visited</span>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<span class="stat-value">{{ city_display|length }}</span>
|
||||
<span class="stat-label">{{ city_display|length == 1 ? 'city' : 'cities' }} visited</span>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<span class="stat-value" id="stat-distance">—</span>
|
||||
<span class="stat-label">km traveled</span>
|
||||
<span class="stat-label">{{ has_gpx ? '🚴 km cycled' : '🧭 km roamed' }}</span>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
{% if temp_min is not null %}
|
||||
<span class="stat-value">{{ temp_min == temp_max ? temp_min : temp_min ~ ' → ' ~ temp_max }}</span>
|
||||
{% else %}
|
||||
<span class="stat-value">—</span>
|
||||
{% endif %}
|
||||
<span class="stat-label">°C range</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if country_display|length > 0 %}
|
||||
<p class="trip-stats-countries">{{ country_display|join(' · ') }}</p>
|
||||
{% endif %}
|
||||
<p class="trip-stats-note">Distance is approximate — straight lines between entry locations.</p>
|
||||
<p class="trip-stats-note">{{ has_gpx ? 'Distance based on GPS track data.' : 'Distance is approximate — straight lines between entry locations.' }}</p>
|
||||
</div>
|
||||
|
||||
{% if has_gpx %}
|
||||
<div id="trip-cycling-block" class="trip-cycling-block" style="display:none">
|
||||
<div class="trip-cycling-header">
|
||||
<span class="trip-cycling-icon">🚴</span>
|
||||
<span class="trip-cycling-title">Cycling Stats</span>
|
||||
</div>
|
||||
<div class="trip-cycling-grid">
|
||||
<div class="stat-block">
|
||||
<span class="stat-value" id="cyc-distance">—</span>
|
||||
<span class="stat-label">km distance</span>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<span class="stat-value" id="cyc-ele-gain">—</span>
|
||||
<span class="stat-label">m ↑ gain</span>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<span class="stat-value" id="cyc-ele-loss">—</span>
|
||||
<span class="stat-label">m ↓ loss</span>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<span class="stat-value" id="cyc-highest">—</span>
|
||||
<span class="stat-label">m highest</span>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<span class="stat-value" id="cyc-lowest">—</span>
|
||||
<span class="stat-label">m lowest</span>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<span class="stat-value" id="cyc-moving-time">—</span>
|
||||
<span class="stat-label">moving time</span>
|
||||
</div>
|
||||
<div class="stat-block">
|
||||
<span class="stat-value" id="cyc-avg-speed">—</span>
|
||||
<span class="stat-label">km/h avg speed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="feed">
|
||||
{% if all_items|length > 0 %}
|
||||
{% for item in all_items %}
|
||||
@@ -309,32 +391,132 @@ setTimeout(function () { tripMap.resize(); }, 100);
|
||||
})();
|
||||
|
||||
var STATS_GPS = {{ gps_points|json_encode|raw }};
|
||||
var HAS_GPX = {{ has_gpx ? 'true' : 'false' }};
|
||||
|
||||
function haversineKm(lat1, lng1, lat2, lng2) {
|
||||
var R = 6371;
|
||||
var dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
var dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
var a = Math.sin(dLat/2)*Math.sin(dLat/2) +
|
||||
Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*
|
||||
Math.sin(dLng/2)*Math.sin(dLng/2);
|
||||
return R * 2 * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
function parseGpxFiles(urls, callback) {
|
||||
var pending = urls.length;
|
||||
var masterPts = [];
|
||||
if (pending === 0) { callback({ error: 'no files' }); return; }
|
||||
urls.forEach(function(url) {
|
||||
fetch(url)
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(text) {
|
||||
var xml = new DOMParser().parseFromString(text, 'text/xml');
|
||||
var trkpts = xml.querySelectorAll('trkpt');
|
||||
trkpts.forEach(function(pt) {
|
||||
var eleEl = pt.querySelector('ele');
|
||||
var timeEl = pt.querySelector('time');
|
||||
masterPts.push({
|
||||
lat: parseFloat(pt.getAttribute('lat')),
|
||||
lon: parseFloat(pt.getAttribute('lon')),
|
||||
ele: eleEl ? parseFloat(eleEl.textContent) : NaN,
|
||||
time: timeEl ? timeEl.textContent : null
|
||||
});
|
||||
});
|
||||
pending--;
|
||||
if (pending === 0) { computeAndCallback(); }
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.warn('GPX load failed:', url, err);
|
||||
pending--;
|
||||
if (pending === 0) { computeAndCallback(); }
|
||||
});
|
||||
});
|
||||
|
||||
function computeAndCallback() {
|
||||
var n = masterPts.length;
|
||||
if (n < 2) { callback({ distance: 0 }); return; }
|
||||
var distance = 0, eleGain = 0, eleLoss = 0;
|
||||
var highest = NaN, lowest = NaN, movingTime = 0;
|
||||
for (var i = 1; i < n; i++) {
|
||||
var p0 = masterPts[i-1], p1 = masterPts[i];
|
||||
var d = haversineKm(p0.lat, p0.lon, p1.lat, p1.lon);
|
||||
distance += d;
|
||||
if (!isNaN(p0.ele) && !isNaN(p1.ele)) {
|
||||
var dEle = p1.ele - p0.ele;
|
||||
if (dEle > 1) eleGain += dEle - 1;
|
||||
else if (dEle < -1) eleLoss += (-dEle) - 1;
|
||||
if (isNaN(highest) || p1.ele > highest) highest = p1.ele;
|
||||
if (isNaN(lowest) || p1.ele < lowest) lowest = p1.ele;
|
||||
}
|
||||
if (p0.time && p1.time) {
|
||||
var dtHrs = (Date.parse(p1.time) - Date.parse(p0.time)) / 3600000;
|
||||
if (dtHrs > 0) {
|
||||
var speed = d / dtHrs;
|
||||
if (speed >= 1) movingTime += dtHrs;
|
||||
}
|
||||
}
|
||||
}
|
||||
// include first point in elevation range
|
||||
if (!isNaN(masterPts[0].ele)) {
|
||||
if (isNaN(highest) || masterPts[0].ele > highest) highest = masterPts[0].ele;
|
||||
if (isNaN(lowest) || masterPts[0].ele < lowest) lowest = masterPts[0].ele;
|
||||
}
|
||||
var avgSpeed = movingTime > 0 ? distance / movingTime : 0;
|
||||
var movHours = Math.floor(movingTime);
|
||||
var movMins = Math.round((movingTime - movHours) * 60);
|
||||
if (movMins === 60) { movHours++; movMins = 0; }
|
||||
callback({
|
||||
distance: distance,
|
||||
eleGain: eleGain,
|
||||
eleLoss: eleLoss,
|
||||
highest: highest,
|
||||
lowest: lowest,
|
||||
movingTime: movHours + ':' + (movMins < 10 ? '0' : '') + movMins,
|
||||
avgSpeed: avgSpeed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
(function() {
|
||||
function haversine(lat1, lng1, lat2, lng2) {
|
||||
var R = 6371;
|
||||
var dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
var dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
return R * 2 * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
var totalKm = 0;
|
||||
for (var i = 1; i < STATS_GPS.length; i++) {
|
||||
totalKm += haversine(
|
||||
parseFloat(STATS_GPS[i - 1][0]), parseFloat(STATS_GPS[i - 1][1]),
|
||||
parseFloat(STATS_GPS[i][0]), parseFloat(STATS_GPS[i][1])
|
||||
);
|
||||
}
|
||||
var distEl = document.getElementById('stat-distance');
|
||||
if (distEl) {
|
||||
distEl.textContent = STATS_GPS.length < 2 ? '—' : '~' + Math.round(totalKm).toLocaleString();
|
||||
|
||||
if (HAS_GPX) {
|
||||
parseGpxFiles(GPX_URLS, function(result) {
|
||||
// Mode A: update distance stat
|
||||
if (distEl) {
|
||||
distEl.textContent = result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—';
|
||||
}
|
||||
// Populate cycling panel
|
||||
function setText(id, val) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
setText('cyc-distance', result.distance > 0 ? Math.round(result.distance).toLocaleString() : '—');
|
||||
setText('cyc-ele-gain', !isNaN(result.eleGain) ? Math.round(result.eleGain) : '—');
|
||||
setText('cyc-ele-loss', !isNaN(result.eleLoss) ? Math.round(result.eleLoss) : '—');
|
||||
setText('cyc-highest', !isNaN(result.highest) ? Math.round(result.highest) : '—');
|
||||
setText('cyc-lowest', !isNaN(result.lowest) ? Math.round(result.lowest) : '—');
|
||||
setText('cyc-moving-time', result.movingTime || '—');
|
||||
setText('cyc-avg-speed', result.avgSpeed > 0 ? result.avgSpeed.toFixed(1) : '—');
|
||||
});
|
||||
} else {
|
||||
// Mode B: haversine between entry points
|
||||
var total = 0;
|
||||
for (var i = 1; i < STATS_GPS.length; i++) {
|
||||
total += haversineKm(
|
||||
parseFloat(STATS_GPS[i-1][0]), parseFloat(STATS_GPS[i-1][1]),
|
||||
parseFloat(STATS_GPS[i][0]), parseFloat(STATS_GPS[i][1])
|
||||
);
|
||||
}
|
||||
if (distEl) {
|
||||
distEl.textContent = STATS_GPS.length < 2 ? '—' : '~' + Math.round(total).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
// Stats toggle
|
||||
var statsToggle = document.getElementById('trip-stats-toggle');
|
||||
var statsBlock = document.getElementById('trip-stats-block');
|
||||
var statsBlock = document.getElementById('trip-stats-block');
|
||||
if (statsToggle && statsBlock) {
|
||||
statsToggle.addEventListener('click', function() {
|
||||
var isOpen = statsBlock.style.display !== 'none';
|
||||
@@ -342,6 +524,17 @@ var STATS_GPS = {{ gps_points|json_encode|raw }};
|
||||
statsToggle.classList.toggle('is-active', !isOpen);
|
||||
});
|
||||
}
|
||||
|
||||
// Cycling toggle (only present when has_gpx)
|
||||
var cycToggle = document.getElementById('trip-cycling-toggle');
|
||||
var cycBlock = document.getElementById('trip-cycling-block');
|
||||
if (cycToggle && cycBlock) {
|
||||
cycToggle.addEventListener('click', function() {
|
||||
var isOpen = cycBlock.style.display !== 'none';
|
||||
cycBlock.style.display = isOpen ? 'none' : '';
|
||||
cycToggle.classList.toggle('is-active', !isOpen);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user