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:
@@ -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';
|
||||
});
|
||||
|
||||
@@ -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}}
|
||||
|
||||
+21
-3
@@ -837,8 +837,10 @@ aside.sidebar:empty { display: none; }
|
||||
Server-rendered inline SVG charts. Geometry comes precomputed from Go;
|
||||
colors and strokes are applied here via classes so the inline SVG follows
|
||||
the theme palette. */
|
||||
.fitness-chart { padding: var(--space-3); }
|
||||
.fitness-chart { position: relative; padding: var(--space-3); }
|
||||
.fitness-chart-header { margin-bottom: var(--space-2); }
|
||||
.fitness-controls { gap: var(--space-3); }
|
||||
.fitness-absolute { display: flex; align-items: center; gap: var(--space-1); font-size: var(--font-sm); color: var(--text-muted); }
|
||||
.fitness-range { width: auto; font-size: var(--font-sm); }
|
||||
.fitness-empty {
|
||||
border: var(--border-dashed);
|
||||
@@ -852,8 +854,24 @@ aside.sidebar:empty { display: none; }
|
||||
.fitness-svg .chart-label { fill: var(--text-muted); font-size: var(--font-xs); }
|
||||
.fitness-svg .chart-line { fill: none; stroke: var(--link); stroke-width: 1.5; }
|
||||
.fitness-svg .chart-dot { fill: var(--link); }
|
||||
.fitness-svg .chart-goal { stroke: var(--primary-hover); stroke-dasharray: 4 3; }
|
||||
.fitness-svg .chart-goal-label { fill: var(--primary-hover); font-size: var(--font-xs); }
|
||||
.fitness-svg.is-dense .chart-dot { display: none; }
|
||||
.fitness-svg:not(.is-hover) :is(.chart-cursor, .chart-focus) { display: none; }
|
||||
.fitness-svg .chart-cursor { stroke: var(--text-muted); stroke-dasharray: 2 2; }
|
||||
.fitness-svg .chart-focus { fill: var(--link-hover); stroke: var(--bg); stroke-width: 1.5; }
|
||||
.fitness-svg .chart-goal { stroke: var(--danger); stroke-dasharray: 4 3; }
|
||||
.fitness-svg .chart-goal-label { fill: var(--danger-hover); font-size: var(--font-xs); }
|
||||
.fitness-tip {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg);
|
||||
border: var(--border);
|
||||
font-size: var(--font-sm);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
.fitness-tip[hidden] { display: none; }
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 1100px) {
|
||||
|
||||
Reference in New Issue
Block a user