← back to Lifestyle Asset Intel
views/partials/sparkline.ejs
84 lines
<%
// sparkline.ejs — pure SSR inline-SVG line chart.
//
// locals: points (Array<{as_of, value}>), width?, height?, stroke?,
// showLabels? (default true), labelGutter? (default 52)
//
// Renders <span class="muted">— insufficient data —</span> if fewer than 2
// numeric points are available. Otherwise an inline SVG line + per-point dots,
// padded 4px on each edge so the strokes don't get clipped. When showLabels
// is true (default), a left-side gutter renders min/max value labels so the
// trend is readable without a tooltip — yolo tick #20.
var _spark_points = (typeof points !== 'undefined' && Array.isArray(points)) ? points : [];
var _spark_w = (typeof width !== 'undefined' && width) ? Number(width) : 320;
var _spark_h = (typeof height !== 'undefined' && height) ? Number(height) : 60;
var _spark_st = (typeof stroke !== 'undefined' && stroke) ? String(stroke) : 'currentColor';
var _spark_lab = (typeof showLabels !== 'undefined') ? !!showLabels : true;
var _spark_gut = (typeof labelGutter !== 'undefined' && labelGutter != null)
? Number(labelGutter) : 52;
function _spark_money(n) {
var v = Math.abs(n);
if (v >= 1e6) return '$' + (n / 1e6).toFixed(v >= 10e6 ? 0 : 1) + 'M';
if (v >= 1e3) return '$' + (n / 1e3).toFixed(v >= 10e3 ? 0 : 1) + 'K';
return '$' + Math.round(n).toLocaleString('en-US');
}
var _spark_clean = [];
for (var _i = 0; _i < _spark_points.length; _i++) {
var _p = _spark_points[_i];
if (_p == null) continue;
var _v = _p.value;
if (_v == null) continue;
var _n = (typeof _v === 'string') ? parseFloat(_v) : _v;
if (!isFinite(_n)) continue;
_spark_clean.push({ as_of: _p.as_of, value: _n });
}
if (_spark_clean.length < 2) {
%><span class="muted">— insufficient data —</span><%
} else {
var _pad = 4;
var _gutter = _spark_lab ? _spark_gut : 0;
var _plotL = _pad + _gutter;
var _innerW = Math.max(1, _spark_w - _plotL - _pad);
var _innerH = Math.max(1, _spark_h - _pad * 2);
var _minY = Infinity, _maxY = -Infinity;
for (var _j = 0; _j < _spark_clean.length; _j++) {
if (_spark_clean[_j].value < _minY) _minY = _spark_clean[_j].value;
if (_spark_clean[_j].value > _maxY) _maxY = _spark_clean[_j].value;
}
var _span = (_maxY - _minY) || 1;
var _step = _spark_clean.length > 1 ? _innerW / (_spark_clean.length - 1) : 0;
var _coords = _spark_clean.map(function (pt, idx) {
var x = _plotL + _step * idx;
var y = _pad + _innerH - ((pt.value - _minY) / _span) * _innerH;
return { x: x, y: y };
});
var _path = _coords.map(function (c, idx) {
return (idx === 0 ? 'M' : 'L') + c.x.toFixed(2) + ' ' + c.y.toFixed(2);
}).join(' ');
var _maxLabel = _spark_money(_maxY);
var _minLabel = _spark_money(_minY);
var _aria = 'price trend, ' + _spark_clean.length + ' points, range '
+ _minLabel + ' to ' + _maxLabel;
%><svg class="spark" xmlns="http://www.w3.org/2000/svg"
width="<%= _spark_w %>" height="<%= _spark_h %>"
viewBox="0 0 <%= _spark_w %> <%= _spark_h %>"
role="img" aria-label="<%= _aria %>">
<% if (_spark_lab) { %>
<text class="spark-axis" x="<%= (_plotL - 4).toFixed(2) %>" y="<%= (_pad + 4).toFixed(2) %>"
text-anchor="end" dominant-baseline="hanging"><%= _maxLabel %></text>
<text class="spark-axis" x="<%= (_plotL - 4).toFixed(2) %>" y="<%= (_pad + _innerH).toFixed(2) %>"
text-anchor="end" dominant-baseline="alphabetic"><%= _minLabel %></text>
<line class="spark-axis-line" x1="<%= _plotL.toFixed(2) %>" y1="<%= _pad.toFixed(2) %>"
x2="<%= _plotL.toFixed(2) %>" y2="<%= (_pad + _innerH).toFixed(2) %>"/>
<% } %>
<path d="<%= _path %>" fill="none" stroke="<%= _spark_st %>" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round"/>
<% _coords.forEach(function (c) { %><circle cx="<%= c.x.toFixed(2) %>" cy="<%= c.y.toFixed(2) %>" r="2.2" fill="<%= _spark_st %>"/><% }); %>
</svg><%
}
%>