Improvements to fitness page

- Data now comes from a calendar
- Hovering in charts shows day info
- Optical enhancements to chart rendering
- Allow setting/showing goal weight
- Allow toggeling between absolute and zoomed in weight axis
This commit is contained in:
2026-09-27 09:31:01 +02:00
parent 4116a0c332
commit 189635d103
7 changed files with 365 additions and 189 deletions
+70 -6
View File
@@ -1,10 +1,74 @@
// Fitness dashboard range dropdowns: changing one reloads the page with that
// chart's query parameter updated. Plain GET navigation — each range is a
// distinct, bookmarkable view, so no postReplace/history handling is needed.
// Fitness dashboard range dropdowns and "absolute" checkboxes: changing one
// reloads the page with that chart's query parameter updated. Plain GET
// navigation — each view is distinct and bookmarkable, so no
// postReplace/history handling is needed.
document.addEventListener('change', function (e) {
var sel = e.target.closest('[data-fitness-range]');
if (!sel) return;
var url = new URL(window.location.href);
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
var sel = e.target.closest('[data-fitness-range]');
var abs = e.target.closest('[data-fitness-absolute]');
if (sel) {
url.searchParams.set(sel.dataset.fitnessRange, sel.value);
} else if (abs) {
if (abs.checked) url.searchParams.set(abs.dataset.fitnessAbsolute, '1');
else url.searchParams.delete(abs.dataset.fitnessAbsolute);
} else {
return;
}
window.location.href = url.toString();
});
// Hover tooltip: the pointer snaps to the data point nearest in time. Dots
// are sorted by x, so a binary search finds it; the tooltip sits above the
// point and is clamped to the chart panel.
function fitnessNearest(dots, x) {
var lo = 0, hi = dots.length - 1;
while (lo < hi) {
var mid = (lo + hi) >> 1;
if (dots[mid].cx.baseVal.value < x) lo = mid + 1; else hi = mid;
}
if (lo > 0 && x - dots[lo - 1].cx.baseVal.value < dots[lo].cx.baseVal.value - x) lo--;
return dots[lo];
}
function fitnessHide(svg) {
var chart = svg.closest('.fitness-chart');
svg.classList.remove('is-hover');
chart.querySelector('.fitness-tip').hidden = true;
}
document.addEventListener('pointermove', function (e) {
var svg = e.target.closest && e.target.closest('.fitness-svg');
document.querySelectorAll('.fitness-svg').forEach(function (other) {
if (other !== svg) fitnessHide(other);
});
if (!svg) return;
var dots = svg.querySelectorAll('.chart-dot');
if (!dots.length) return;
var ctm = svg.getScreenCTM();
if (!ctm) return;
var pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
var dot = fitnessNearest(dots, pt.x);
var cx = dot.cx.baseVal.value, cy = dot.cy.baseVal.value;
var cursor = svg.querySelector('.chart-cursor');
cursor.setAttribute('x1', cx);
cursor.setAttribute('x2', cx);
var focus = svg.querySelector('.chart-focus');
focus.setAttribute('cx', cx);
focus.setAttribute('cy', cy);
svg.classList.add('is-hover');
var chart = svg.closest('.fitness-chart');
var tip = chart.querySelector('.fitness-tip');
tip.firstElementChild.textContent = dot.dataset.date;
tip.lastElementChild.textContent = dot.dataset.value;
tip.hidden = false;
var box = chart.getBoundingClientRect();
var at = new DOMPoint(cx, cy).matrixTransform(ctm);
var left = at.x - box.left - tip.offsetWidth / 2;
left = Math.max(0, Math.min(left, box.width - tip.offsetWidth));
var top = at.y - box.top - tip.offsetHeight - 10;
if (top < 0) top = at.y - box.top + 10;
tip.style.left = left + 'px';
tip.style.top = top + 'px';
});
+12 -5
View File
@@ -2,19 +2,23 @@
<section class="fitness-chart panel">
<div class="fitness-chart-header row space-between">
<span class="caption">{{.Title}}</span>
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
<div class="row fitness-controls">
<label class="fitness-absolute"><input type="checkbox" data-fitness-absolute="{{.Param}}-absolute"{{if .Absolute}} checked{{end}}> Absolute</label>
<select class="input fitness-range" data-fitness-range="{{.Param}}" aria-label="{{.Title}} time range">
{{range .Options}}<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>{{end}}
</select>
</div>
</div>
{{if .Empty}}
<p class="fitness-empty is-empty">No data in this range.</p>
{{else}}
<svg class="fitness-svg" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
<svg class="fitness-svg{{if .Dense}} is-dense{{end}}" viewBox="0 0 {{.ViewW}} {{.ViewH}}" role="img" aria-label="{{.Title}}">
{{range .YTicks}}
<line class="chart-grid" x1="{{$.PlotX}}" y1="{{.Pos}}" x2="{{$.PlotR}}" y2="{{.Pos}}"/>
<text class="chart-label" x="{{$.YLabelX}}" y="{{.Pos}}" text-anchor="end" dominant-baseline="middle">{{.Label}}</text>
{{end}}
{{range .XTicks}}
<line class="chart-grid" x1="{{.Pos}}" y1="{{$.PlotY}}" x2="{{.Pos}}" y2="{{$.PlotB}}"/>
<text class="chart-label" x="{{.Pos}}" y="{{$.XLabelY}}" text-anchor="{{.Anchor}}">{{.Label}}</text>
{{end}}
<line class="chart-axis" x1="{{.PlotX}}" y1="{{.PlotY}}" x2="{{.PlotX}}" y2="{{.PlotB}}"/>
@@ -23,13 +27,16 @@
<polyline class="chart-line" points="{{.}}"/>
{{end}}
{{range .Dots}}
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5"><title>{{.Title}}</title></circle>
<circle class="chart-dot" cx="{{.X}}" cy="{{.Y}}" r="2.5" data-date="{{.Date}}" data-value="{{.Value}}"/>
{{end}}
{{if .Goal}}
<line class="chart-goal" x1="{{.PlotX}}" y1="{{.Goal.Y}}" x2="{{.PlotR}}" y2="{{.Goal.Y}}"/>
<text class="chart-goal-label" x="{{.PlotR}}" y="{{.Goal.LabelY}}" text-anchor="end">{{.Goal.Label}}</text>
{{end}}
<line class="chart-cursor" x1="0" y1="{{.PlotY}}" x2="0" y2="{{.PlotB}}"/>
<circle class="chart-focus" r="4"/>
</svg>
<div class="fitness-tip" hidden><strong></strong><span></span></div>
{{end}}
</section>
{{end}}