shown
Version:
Statically-generated, responsive charts, without the need for client-side Javascript.
490 lines (434 loc) • 13.4 kB
JavaScript
import $ from "../lib/dom/index.js"
import percent from "../lib/utils/percent.js"
import sum from "../lib/utils/sum.js"
import interpolate from "../lib/utils/interpolate.js"
import { atan2, isFinite } from "../lib/utils/math.js"
import curve from "../lib/curve.js"
import Map from "../lib/map.js"
import legendTemplate from "./legend.js"
import symbolTemplate from "./symbol.js"
import { default as axisTemplate, setup as setupAxis } from "./axis.js"
import wrap from "./wrap.js"
const SVGLINE_VIEWPORT_W = 100
const SVGLINE_VIEWPORT_H = SVGLINE_VIEWPORT_W
// If label points are shifted horizontally above the threshold,
// text-anchor is used and the horizontal offset is fixed so that
// the closest glyph is centered above the point.
const labelAnchorThreshold = 0.3
/**
* @private
* @typedef {string[]} Color
*/
/**
* @private
* @typedef {Object} Point
* @property {number} x
* @property {number} y
* @property {number} value
* @property {boolean} tally
* @property {string} curve
* @property {string} shape
* @property {string} key
* @property {Color} color
* @property {string} label
*/
/**
* A line may include points with multiple curve types. This function
* groups the points by curve type, renders these groups using their
* respective curve functions, and joins them together into a path.
* @private
* @param {number[]} points
* @param {Axis} xAxis
* @param {Axis} yAxis
* @param {boolean} showGaps
* @returns {string} path
*/
const linePath = (points, xAxis, yAxis, showGaps) => {
let path = points
.reduce((m, d) => {
const l = m[m.length - 1]
if (!(isFinite(d.x) && isFinite(d.y))) {
if (showGaps) {
return [...m, { curve: d.curve, points: [] }]
} else {
return m
}
}
const p = [
SVGLINE_VIEWPORT_W * xAxis.scale(d.x),
SVGLINE_VIEWPORT_H * (1 - yAxis.scale(d.y)),
]
if (l) l.points.push(p)
if (!l || l.curve !== d.curve) {
return [...m, { curve: d.curve, points: [p] }]
}
return m
}, [])
.map((l) => {
let args = []
let path
let type = l.curve
// Some curve types allow other parameters to be passed to the curve
// function. For example, setting the tension on "monotone" or "bump".
if (Array.isArray(type)) {
;[type, ...args] = type
}
path = curve[type](l.points, ...args)
return path
})
.join("")
return path
}
/**
* An area path is constructed using the linePath of the current and next line,
* or the baseline when it is the final line.
* @private
* @param {number[]} line1
* @param {number[]} line2
* @param {Axis} xAxis
* @param {Axis} yAxis
* @returns {string} path
*/
const areaPath = (line1, line2, xAxis, yAxis) => {
const path1 = linePath(line1, xAxis, yAxis, false)
// The first path reverses along the baseline
if (!line2) {
return (
path1 +
`L${SVGLINE_VIEWPORT_W},${SVGLINE_VIEWPORT_H}L0,${SVGLINE_VIEWPORT_H}Z`
)
}
// Some curves are asymmetric and need to be mirrored
const mirror = {
stepX: "stepY",
stepY: "stepX",
}
line2 = line2
.map((d) => ({
...d,
curve: mirror[d.curve] || d.curve,
}))
.reverse()
const path2 = linePath(line2, xAxis, yAxis, false)
return path1 + "L" + path2.slice(1) + "Z"
}
/**
* Generate a line chart.
* @alias module:shown.line
* @param {Object} options - Data and display options for the chart.
* @param {any[]} options.data - The data for this chart. Data can
* be passed either as a flat array for a single line, or nested arrays
* for multiple lines.
* @param {string} [options.title] - The title for this chart, set to the
* `<title>` element for better accessibility.
* @param {string} [options.description] - The description for this chart, set
* to the `<desc>` element for better accessibility.
* @param {MapOptions} [options.map]
* Controls for transforming data. See {@link MapOptions} for more details.
* @param {AxisOptions} [options.xAxis]
* Overrides for the x-axis. See {@link AxisOptions} for more details.
* @param {AxisOptions} [options.yAxis]
* Overrides for the y-axis. See {@link AxisOptions} for more details.
* @param {Boolean} [options.showGaps]
* Points in the line with non-finite values are rendered as broken lines
* where data is unavailable. Set to `false` to skip missing values instead.
* @param {Boolean} [options.area] Render the line chart as an area chart.
* @param {Boolean} [options.scatter] Render the line chart as a scatter plot.
* @param {Boolean} [options.sorted] - Whether to sort the values.
* @param {Boolean} [options.smartLabels] - Labels are shifted to minimise
* overlapping the line.
* @returns {string} Rendered chart
*
* @example
* shown.line({
* title: "Custom axis",
* data: [
* -0.0327, 0.05811, 0.18046, 0.27504, 0.43335, 0.43815,
* 0.54249, 0.57011, 0.54897, 0.60961, 0.58727, 0.53557,
* 0.55060, NaN, null, undefined, false, 0.02642,
* -0.0097, -0.1826, -0.2999, -0.3352, -0.4735, -0.4642,
* -0.5720, -0.6065, -0.5761, -0.5724, -0.6096, -0.5314,
* -0.4492, -0.4007, -0.3008, -0.1924, -0.0696, 0.00279,
* ],
* xAxis: { min: 1988, max: 2023 },
* yAxis: { ticks: 15, label: (v, i) => (i % 2 !== 0) && v.toFixed(1) },
* })
*
* @example
* shown.line({
* title: "Map x and y data",
* data: [{x: 0, y: 1}, {x: 1, y: -1}, {x: 2, y: 1}],
* map: {
* x: (d) => d.x,
* y: (d) => d.y,
* curve: "bump",
* }
* })
*
* @example
* shown.line({
* title: "Multiple lines, curves and shapes",
* data: [
* [52.86, 20.65, 14.54, 10.09, 41.86],
* [21.97, 31.71, 56.94, 17.85, 23.53],
* [ 6.73, 10.84, 37.62, 45.79, 53.32],
* [38.44, 50.79, 22.31, 31.82, 7.64],
* ],
* map: {
* curve: ["linear", "bump", "monotone", "stepX"],
* shape: ["circle", "square", "triangle", "diamond"],
* key: ["α", "β", "γ", "δ"],
* },
* xAxis: { label: ["A", "B", "C", "D", "E"], inset: 0.1 },
* })
*
* @example
* shown.line({
* title: "Point labels",
* data: [
* [3127, 2106, 1849, null, 4397, 3347],
* [3952, 4222, 4640, 2579, 1521, 1342],
* ],
* map: {
* curve: "monotone",
* shape: "circle",
* color: ["#d4a", "#f84"],
* key: ["Type I", "Type II"],
* label: true,
* },
* xAxis: { inset: 0.1 },
* yAxis: { min: 0, label: (v) => Math.round(v / 1000) + "k" },
* })
*/
export default ({
data,
title,
description,
map,
showGaps = true,
xAxis,
yAxis,
area = false,
scatter = false,
sorted = false,
smartLabels = true,
}) => {
data = Array.isArray(data[0]) ? data : [data]
const maxLength = Math.max(...data.map((d) => d.length))
map = new Map(
{
curve: "linear",
shape: false,
label: false,
x: (d, i, j) => {
const min = (xAxis || {}).min ?? 0
const max = (xAxis || {}).max ?? min + (maxLength - 1)
return min + (j / (maxLength - 1)) * (max - min)
},
y: (d) => d,
...map,
},
data.flat(),
{ minValue: -Infinity, colors: data.length, maxDepth: 2 }
)
data = map(data)
const hasLines = !scatter && !!data.find((line) => line.find((d) => d.curve))
if (sorted) {
data.sort((al, bl) => {
const a = sum(al)
const b = sum(bl)
return a === b ? 0 : a > b ? 1 : -1
})
}
if (area) {
const longest = data.find((line) => line.length === maxLength)
// For lines with fewer points, continue along the baseline
data.forEach((line) => {
const curve = line.slice(-1)[0]?.curve
while (line.length < maxLength) {
line.push({
x: longest[line.length]?.x,
y: 0,
curve,
})
}
})
// If a line is discontinuous at a point where the previous is not,
// interpolate an average value for that point
data.slice(1).forEach((next, j) => {
const prev = data[j]
const prevBase = interpolate(prev.map((d) => d.y))
const nextBase = interpolate(next.map((d) => d.y))
next.forEach((item, i) => {
if (isFinite(prev[i]?.y) && !isFinite(item.y)) {
item.y = nextBase[i]
}
if (isFinite(item.y)) {
item.y += prevBase[i]
}
})
})
}
// prettier-ignore
const axes = {
x: setupAxis(xAxis, data.flat().map((d) => d.x), scatter),
y: setupAxis(yAxis, data.flat().map((d) => d.y))
}
const axisX = axisTemplate("x", axes.x)
const axisY = axisTemplate("y", axes.y)
const viewBox = `0 0 ${SVGLINE_VIEWPORT_W} ${SVGLINE_VIEWPORT_H}`
const areas =
area &&
$.svg({
class: "areas",
viewBox,
preserveAspectRatio: "none",
style: (axes.x.hasOverflow || axes.y.hasOverflow) && "overflow: hidden;",
})(
data
.filter((line) => line.length > 0)
.reverse()
.map((line, i, arr) =>
$.path({
"class": ["series", "series-" + i],
"vector-effect": "non-scaling-stroke",
"fill": line[0].color[0],
"d": areaPath(line, arr[i + 1], axes.x, axes.y),
})
)
)
const lines =
hasLines &&
$.svg({
class: "lines",
viewBox,
preserveAspectRatio: "none",
style: (axes.x.hasOverflow || axes.y.hasOverflow) && "overflow: hidden;",
})(
data
.filter((line) => line.length > 0 && !!line.find((d) => d.curve))
.map((line, i) =>
$.path({
"class": ["series", "series-" + i],
"vector-effect": "non-scaling-stroke",
"stroke": line[0].color[0],
"fill": "none",
"d": linePath(line, axes.x, axes.y, showGaps),
})
)
)
const points = $.svg({
class: "points",
})(
data.map((data, j) =>
$.svg({
"class": ["series", "series-" + j],
"color": data[0]?.color[0],
"text-anchor": "middle",
"dominant-baseline": "central",
})(
data.map((d, i) => {
if (!isFinite(d.x) || !isFinite(d.y)) return
let shape
let label
if (d.shape) {
shape = $.use({
href: `#symbol-${d.shape}`,
width: "1em",
height: "1em",
class: "symbol",
color: data[0].color[0] !== d.color[0] && d.color[0],
})
}
if (d.label || d.label === 0) {
let x = axes.x.scale(d.x)
let y = 1 - axes.y.scale(d.y)
let dx = 0
let dy = -1
let textAnchor
const prev = data[i - 1]
const next = data[i + 1]
if (smartLabels && (prev || next)) {
let nx = !!next && isFinite(next.x) && axes.x.scale(next.x)
let px = !!prev && isFinite(prev.x) && axes.x.scale(prev.x)
let ny = !!next && isFinite(next.y) && 1 - axes.y.scale(next.y)
let py = !!prev && isFinite(prev.y) && 1 - axes.y.scale(prev.y)
if (nx === false || ny === false) {
nx = x + (x - px)
ny = y + (y - py)
}
if (px === false || py === false) {
px = x - (nx - x)
py = y - (ny - y)
}
const pdx = x - px
const pdy = y - py
const ndx = nx - x
const ndy = ny - y
const nt = atan2(ndy, ndx)
const pt = atan2(pdy, pdx)
const theta = (nt + pt) / 2 - Math.PI / 2
dx = Math.cos(theta)
dy = -0.75
if (Math.abs(dx) > labelAnchorThreshold) {
textAnchor = dx > 0 ? "start" : "end"
dx = labelAnchorThreshold * (dx > 0 ? -1 : 1)
}
}
if (d.r > 1) dy = dy * d.r
if (d.shape) dy -= 0.25
label = $.text({
"class": "label",
"dx": +dx.toFixed(2) + "em",
"dy": +dy.toFixed(2) + "em",
"text-anchor": textAnchor,
})(d.label)
}
if (shape || label) {
return $.svg({
class: "point",
x: percent(axes.x.scale(d.x)),
y: percent(1 - axes.y.scale(d.y)),
attrs: d.attrs,
})([shape, label])
}
})
)
)
)
const defs = symbolTemplate(data)
return wrap(
$.div({
class: [
"chart",
area ? "chart-area" : scatter ? "chart-scatter" : "chart-line",
axes.x.label && "has-xaxis xaxis-w" + axes.x.width,
axes.y.label && "has-yaxis yaxis-w" + axes.y.width,
],
})([
$.div({
style: {
"flex-grow": 1,
"height": 0,
"box-sizing": "border-box",
},
})(
$.svg({
xmlns: "http://www.w3.org/2000/svg",
width: "100%",
height: "100%",
})([
defs && $.defs()(defs),
title && $.title()(title),
description && $.desc()(description),
axisY,
axisX,
areas,
lines,
points,
])
),
legendTemplate({ data, line: true }),
])
)
}