← 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><%
}
%>