Comparing version 1.0.7 to 1.1.0
{ | ||
"name": "shown", | ||
"version": "1.0.7", | ||
"version": "1.1.0", | ||
"description": "Statically-generated, responsive charts, without the need for client-side Javascript.", | ||
@@ -21,6 +21,9 @@ "type": "module", | ||
"badge": "make-coverage-badge --output-path=coverage.svg", | ||
"watch": "nodemon --exec 'npm run build'", | ||
"build": "run-s build:**", | ||
"build:lib": "esbuild src/index.js src/css/shown.css --bundle --platform=node --target=node10.4 --outdir=dist --out-extension:.js=.cjs", | ||
"build:docs": "documentation build ./src/index.js -f html -g -o docs -t ./docs-theme/index.cjs", | ||
"build:types": "jsdoc -p -t node_modules/tsd-jsdoc/dist -r ./src -d types", | ||
"prepublishOnly": "run-s build:**", | ||
"prepublishOnly": "npm run build", | ||
"prerelease": "npm test", | ||
"release": "standard-version" | ||
@@ -62,2 +65,3 @@ }, | ||
"make-coverage-badge": "^1.2.0", | ||
"nodemon": "^2.0.22", | ||
"npm-run-all": "^4.1.5", | ||
@@ -90,3 +94,11 @@ "pug": "^3.0.2", | ||
"releaseCommitMessageFormat": "Chore: Release {{currentTag}}" | ||
}, | ||
"nodemonConfig": { | ||
"ignore": [ | ||
"**/dist/**", | ||
"**/docs/**", | ||
"**/*.ts" | ||
], | ||
"delay": 2500 | ||
} | ||
} |
@@ -6,2 +6,3 @@ /** @module module:shown */ | ||
export { default as line } from "./templates/line.js" | ||
export { default as area } from "./templates/area.js" | ||
export { default as scatter } from "./templates/scatter.js" | ||
@@ -8,0 +9,0 @@ export { default as legend } from "./templates/legend.js" |
@@ -0,1 +1,3 @@ | ||
import { floor } from "./utils/math.js" | ||
// prettier-ignore | ||
@@ -57,3 +59,3 @@ const DEFAULT_COLORS = [ | ||
const c = Math.floor(t * (DEFAULT_COLORS.length - 1)) | ||
const c = floor(t * (DEFAULT_COLORS.length - 1)) | ||
@@ -60,0 +62,0 @@ return t < 0.6 ? [DEFAULT_COLORS[c], "#fff"] : DEFAULT_COLORS[c] |
@@ -5,17 +5,19 @@ import monotone from "./curve/monotone.js" | ||
import { stepX, stepY, stepMidX, stepMidY } from "./curve/step.js" | ||
import { isFinite } from "./utils/math.js" | ||
const FIXED = 2 | ||
const filter = (p) => Number.isFinite(p[0]) && Number.isFinite(p[1]) | ||
const finite = (line) => line.filter(([x, y]) => isFinite(x) && isFinite(y)) | ||
const toPath = (path, d) => | ||
path + | ||
// Add spaces between consecutive numbers (unless negative) | ||
(d >= 0 && isFinite(+path.slice(-1)[0]) ? " " : "") + | ||
// Limit decimals in the path string | ||
(isFinite(d) ? +d.toFixed(FIXED) : d) | ||
const wrap = | ||
(fn) => | ||
(p, ...args) => | ||
fn(p.filter(filter), ...args) | ||
.map( | ||
(v, i, a) => | ||
(Number.isFinite(v) && Number.isFinite(a[i - 1]) ? " " : "") + | ||
(Number.isFinite(v) ? +v.toFixed(FIXED) : v) | ||
) | ||
.join("") | ||
(curve) => | ||
(line, ...args) => | ||
curve(finite(line), ...args).reduce(toPath, "") | ||
@@ -22,0 +24,0 @@ export default { |
@@ -0,4 +1,6 @@ | ||
import { min, abs, clamp } from "../utils/math.js" | ||
const TENSION = 1 / 3 | ||
const sign = (x) => (x < 0 ? -1 : 1) | ||
const sign = (v) => (v < 0 ? -1 : 1) | ||
@@ -12,12 +14,11 @@ const slope = (x0, y0, x1, y1, x2, y2) => { | ||
return ( | ||
(sign(s0) + sign(s1)) * | ||
Math.min(Math.abs(s0), Math.abs(s1), Math.abs(p) / 2) || 0 | ||
) | ||
return (sign(s0) + sign(s1)) * min(abs(s0), abs(s1), abs(p) / 2) || 0 | ||
} | ||
const curve = (x0, y0, x1, y1, s0, s1, alpha) => { | ||
const dx0 = (x1 - x0) * alpha | ||
const dx1 = (x1 - x0) * alpha | ||
return ["C", x0 + dx0, y0 + dx0 * s0, x1 - dx1, y1 - dx1 * s1, x1, y1] | ||
const ratio = abs((y1 - y0) / (x1 - x0)) | ||
const scale = clamp(ratio, 1, 1.5) | ||
const delta = (x1 - x0) * alpha * scale | ||
return ["C", x0 + delta, y0 + delta * s0, x1 - delta, y1 - delta * s1, x1, y1] | ||
} | ||
@@ -24,0 +25,0 @@ |
@@ -8,4 +8,7 @@ export const stepX = (points) => | ||
const dy = p1[1] - p0[1] | ||
if (dx === 0 && dy === 0) return m | ||
return m.concat(["h", dx, "v", dy]) | ||
if (dx !== 0) m.push("h", dx) | ||
if (dy !== 0) m.push("v", dy) | ||
return m | ||
} | ||
@@ -21,4 +24,7 @@ }, []) | ||
const dy = p1[1] - p0[1] | ||
if (dx === 0 && dy === 0) return m | ||
return m.concat(["v", dy, "h", dx]) | ||
if (dy !== 0) m.push("v", dy) | ||
if (dx !== 0) m.push("h", dx) | ||
return m | ||
} | ||
@@ -32,6 +38,10 @@ }, []) | ||
const p0 = points[i - 1] | ||
const dx = p1[0] - p0[0] | ||
const dx = (p1[0] - p0[0]) / 2 | ||
const dy = p1[1] - p0[1] | ||
if (dx === 0 && dy === 0) return m | ||
return m.concat(["h", dx / 2, "v", dy, "h", dx / 2]) | ||
if (dx !== 0) m.push("h", dx) | ||
if (dy !== 0) m.push("v", dy) | ||
if (dx !== 0) m.push("h", dx) | ||
return m | ||
} | ||
@@ -46,6 +56,10 @@ }, []) | ||
const dx = p1[0] - p0[0] | ||
const dy = p1[1] - p0[1] | ||
if (dx === 0 && dy === 0) return m | ||
return m.concat(["v", dy / 2, "h", dx, "v", dy / 2]) | ||
const dy = (p1[1] - p0[1]) / 2 | ||
if (dy !== 0) m.push("v", dy) | ||
if (dx !== 0) m.push("h", dx) | ||
if (dy !== 0) m.push("v", dy) | ||
return m | ||
} | ||
}, []) |
import { get as getColor, wrap as wrapColor } from "./color.js" | ||
import utils from "./utils.js" | ||
import decimalPlaces from "./utils/decimal-places.js" | ||
import { min, max, isFinite } from "./utils/math.js" | ||
@@ -37,3 +38,3 @@ /** | ||
* The default function returns the _index_ of the item. | ||
* **Line and Scatter Chart only** | ||
* **Line, Area and Scatter Chart only** | ||
* @property {Function|number[]|number} [y] | ||
@@ -43,3 +44,3 @@ * Parse the y-axis value from the data. This function is useful if your | ||
* The default function returns the _value_ of the item. | ||
* **Line and Scatter Chart only** | ||
* **Line, Area and Scatter Chart only** | ||
* @property {Function|number[]|number} [r] | ||
@@ -84,3 +85,4 @@ * Parse the radial size from the data. This function is useful if you want to | ||
* function is useful if you want to override or add arbitrary attributes on the | ||
* chart. | ||
* chart. For example, you could add a `data-tooltip` attribute to trigger | ||
* tooltips using a JavaScript library. | ||
*/ | ||
@@ -133,3 +135,3 @@ | ||
const values = data.map(map.y || map.value) | ||
const places = Math.min(Math.max(...values.map(utils.decimalPlaces)), 2) | ||
const places = min(max(...values.map(decimalPlaces)), 2) | ||
@@ -140,8 +142,8 @@ // By default, a label will only show when it exceeds the minimum value | ||
if (map.label === undefined || map.label === true) { | ||
const max = Math.max(...values) | ||
const maxValue = max(...values) | ||
map.label = (v) => | ||
(v = map.value(v)) && | ||
Number.isFinite(v) && | ||
v / max >= minValue && | ||
isFinite(v) && | ||
v / maxValue >= minValue && | ||
v.toFixed(places) | ||
@@ -159,4 +161,3 @@ } | ||
if (map.tally === true) { | ||
map.tally = (v) => | ||
(v = map.value(v)) && Number.isFinite(v) && v.toFixed(places) | ||
map.tally = (v) => (v = map.value(v)) && isFinite(v) && v.toFixed(places) | ||
} | ||
@@ -163,0 +164,0 @@ |
import $ from "../lib/dom/index.js" | ||
import utils from "../lib/utils.js" | ||
// The number of ticks to use, in preferential order. | ||
import decimalPlaces from "../lib/utils/decimal-places.js" | ||
import magnitude from "../lib/utils/magnitude.js" | ||
import percent from "../lib/utils/percent.js" | ||
import toPrecision from "../lib/utils/to-precision.js" | ||
import { | ||
isFinite, | ||
isInteger, | ||
floor, | ||
ceil, | ||
abs, | ||
min, | ||
max, | ||
pow, | ||
} from "../lib/utils/math.js" | ||
// The number of ticks to use, in preferential order | ||
const TICKCOUNT_ORDER = [5, 6, 7, 8, 4, 9, 3, 10, 11, 12, 13] | ||
@@ -51,30 +65,25 @@ const MAX_PRECISION = 7 | ||
* @private | ||
* @param {number} min | ||
* @param {number} max | ||
* @param {number} vmin | ||
* @param {number} vmax | ||
* @returns {number} scale | ||
*/ | ||
const getScale = (min, max) => { | ||
let m = Math.max(utils.magnitude(min), utils.magnitude(max)) | ||
const getScale = (vmin, vmax) => { | ||
let m = max(magnitude(vmin), magnitude(vmax)) | ||
// The magnitude should be no greater than the difference | ||
m = Math.min(m, utils.magnitude(max - min)) | ||
m = min(m, magnitude(vmax - vmin)) | ||
// Deal with integers from here | ||
let scale = Math.pow(10, m) | ||
let scale = pow(10, m) | ||
// If both values are integers and min is positive, | ||
// all ticks should only occur at integer positions | ||
if ( | ||
Number.isInteger(max) && | ||
Number.isInteger(min) && | ||
min > 0 && | ||
scale === 1 | ||
) { | ||
if (isInteger(vmax) && isInteger(vmin) && vmin > 0 && scale === 1) { | ||
return 1 | ||
} | ||
min /= scale | ||
max /= scale | ||
vmin /= scale | ||
vmax /= scale | ||
let d = max - min | ||
let d = vmax - vmin | ||
@@ -94,3 +103,3 @@ // Increase the scale when the difference is too small | ||
LOW_PRIMES.forEach((p) => { | ||
if (min > 0 || max < 0 ? d % p === 0 : min % p === 0 && max % p === 0) { | ||
if (vmin > 0 || vmax < 0 ? d % p === 0 : vmin % p === 0 && vmax % p === 0) { | ||
scale /= p | ||
@@ -113,20 +122,20 @@ } | ||
let min = utils.toPrecision(Math.min(...values), MAX_PRECISION) | ||
let max = utils.toPrecision(Math.max(...values), MAX_PRECISION) | ||
let vmin = toPrecision(min(...values), MAX_PRECISION) | ||
let vmax = toPrecision(max(...values), MAX_PRECISION) | ||
if (min === max) { | ||
if (vmin === vmax) { | ||
// All values are zero | ||
if (max === 0) return [0, 1] | ||
if (vmax === 0) return [0, 1] | ||
// The bounds should be between zero and max | ||
if (min < 0) max = 0 | ||
else min = 0 | ||
if (vmin < 0) vmax = 0 | ||
else vmin = 0 | ||
} | ||
const f = getScale(min, max) | ||
const f = getScale(vmin, vmax) | ||
min = Math.floor(min / f) | ||
max = Math.ceil(max / f) | ||
vmin = floor(vmin / f) | ||
vmax = ceil(vmax / f) | ||
const d = max - min | ||
const d = vmax - vmin | ||
@@ -137,6 +146,6 @@ // The distance should be divisible by a low prime number | ||
if (d % 2 > 0 && !LOW_PRIMES.some((p) => d % p === 0)) { | ||
if (Math.abs(max) % 2 === 1) { | ||
max += 1 | ||
if (abs(vmax) % 2 === 1) { | ||
vmax += 1 | ||
} else { | ||
min -= 1 | ||
vmin -= 1 | ||
} | ||
@@ -146,4 +155,4 @@ } | ||
return [ | ||
utils.toPrecision(min * f, MAX_PRECISION), | ||
utils.toPrecision(max * f, MAX_PRECISION), | ||
toPrecision(vmin * f, MAX_PRECISION), | ||
toPrecision(vmax * f, MAX_PRECISION), | ||
] | ||
@@ -157,20 +166,20 @@ } | ||
* @private | ||
* @param {number} min | ||
* @param {number} max | ||
* @param {number} vmin | ||
* @param {number} vmax | ||
* @returns {number} count | ||
*/ | ||
const getTicks = (min, max) => { | ||
if (!Number.isFinite(min) || !Number.isFinite(max)) return 0 | ||
if (min === max) return 0 | ||
const getTicks = (vmin, vmax) => { | ||
if (!isFinite(vmin) || !isFinite(vmax)) return 0 | ||
if (vmin === vmax) return 0 | ||
const f = getScale(min, max) | ||
const f = getScale(vmin, vmax) | ||
min = utils.toPrecision(min / f, MAX_PRECISION) | ||
max = utils.toPrecision(max / f, MAX_PRECISION) | ||
vmin = toPrecision(vmin / f, MAX_PRECISION) | ||
vmax = toPrecision(vmax / f, MAX_PRECISION) | ||
let d = max - min | ||
let d = vmax - vmin | ||
if (min >= 0 || max <= 0) { | ||
if (vmin >= 0 || vmax <= 0) { | ||
LOW_PRIMES.forEach((p) => { | ||
if (d > Math.max(10, 10 * f) && d % p === 0) { | ||
if (d > max(10, 10 * f) && d % p === 0) { | ||
d /= p | ||
@@ -181,12 +190,12 @@ } | ||
const maxDecimals = utils.decimalPlaces(utils.toPrecision(d, MAX_PRECISION)) | ||
const maxDecimals = decimalPlaces(toPrecision(d, MAX_PRECISION)) | ||
return ( | ||
TICKCOUNT_ORDER.find((n) => { | ||
const mod = utils.toPrecision(d / (n - 1), MAX_PRECISION) | ||
const dec = utils.toPrecision(mod, MAX_PRECISION) | ||
const mod = toPrecision(d / (n - 1), MAX_PRECISION) | ||
const dec = toPrecision(mod, MAX_PRECISION) | ||
return ( | ||
utils.decimalPlaces(dec) <= maxDecimals && | ||
(min > 0 || max < 0 || (min % mod === 0 && max % mod === 0)) | ||
decimalPlaces(dec) <= maxDecimals && | ||
(vmin > 0 || vmax < 0 || (vmin % mod === 0 && vmax % mod === 0)) | ||
) | ||
@@ -205,32 +214,31 @@ }) || 2 | ||
export const setup = (axis = {}, data, guessBounds = true) => { | ||
let { min, max, ticks, label, line, spine, inset } = axis | ||
let _min, _max, hasOverflow, width | ||
let { min: vmin, max: vmax, ticks, label, line, spine, inset } = axis | ||
let bmin, bmax, hasOverflow, width | ||
const showLabel = | ||
!!data || Number.isFinite(axis.min) || Number.isFinite(axis.max) | ||
const showLabel = !!data || isFinite(axis.min) || isFinite(axis.max) | ||
if (data) { | ||
if (guessBounds) { | ||
const values = [min, max, ...data].filter(Number.isFinite) | ||
const values = [vmin, vmax, ...data].filter(isFinite) | ||
// Unless every number is an integer, assume zero should | ||
// appear on either end of the scale | ||
if (values.some((n) => !Number.isInteger(n))) { | ||
if (values.some((n) => !isInteger(n))) { | ||
values.push(0) | ||
} | ||
;[_min, _max] = getBounds(values) | ||
;[bmin, bmax] = getBounds(values) | ||
} else { | ||
_min = Math.min(...data.filter(Number.isFinite)) | ||
_max = Math.max(...data.filter(Number.isFinite)) | ||
bmin = min(...data.filter(isFinite)) | ||
bmax = max(...data.filter(isFinite)) | ||
} | ||
} else { | ||
_min = 0 | ||
_max = ticks - 1 | ||
bmin = 0 | ||
bmax = ticks - 1 | ||
} | ||
hasOverflow = _min < min || _max > max | ||
min = min ?? _min | ||
max = max ?? _max | ||
ticks = ticks ?? getTicks(min, max) | ||
hasOverflow = bmin < vmin || bmax > vmax | ||
vmin = vmin ?? bmin | ||
vmax = vmax ?? bmax | ||
ticks = ticks ?? getTicks(vmin, vmax) | ||
inset = inset ?? 0 | ||
@@ -249,8 +257,8 @@ | ||
// only every second tick will be labeled. | ||
const length = Math.max(Math.abs(max), Math.abs(min)).toString().length | ||
const length = max(abs(vmax), abs(vmin)).toString().length | ||
label = (v, i) => | ||
(ticks < 8 || i % 2 === 0) && | ||
Math.abs(v).toString().length <= length && | ||
utils.toPrecision(v, MAX_PRECISION) | ||
abs(v).toString().length <= length && | ||
toPrecision(v, MAX_PRECISION) | ||
} | ||
@@ -274,3 +282,3 @@ | ||
if (max === min) { | ||
if (vmax === vmin) { | ||
grid = [0.5] | ||
@@ -280,10 +288,11 @@ inset = 0.5 | ||
const scale = (v) => (max === min ? 0.5 : pad((v - min) / (max - min), inset)) | ||
const scale = (v) => | ||
vmax === vmin ? 0.5 : pad((v - vmin) / (vmax - vmin), inset) | ||
if (label) { | ||
width = Math.max( | ||
width = max( | ||
...grid | ||
.map((t) => min + t * (max - min)) | ||
.map((t) => vmin + t * (vmax - vmin)) | ||
.map(label) | ||
.filter((t) => t.length || Number.isFinite(t)) | ||
.filter((t) => t.length || isFinite(t)) | ||
.map((t) => t.toString().length) | ||
@@ -295,6 +304,6 @@ ) | ||
...axis, | ||
min: vmin, | ||
max: vmax, | ||
grid, | ||
ticks, | ||
min, | ||
max, | ||
label, | ||
@@ -319,11 +328,28 @@ line, | ||
const lineProps = type === "x" ? { y2: "100%" } : { x2: "100%" } | ||
const line = (t, className, d) => { | ||
const props = { class: className } | ||
const v = t !== 0 && percent(t) | ||
if (type === "x") { | ||
props.x1 = v | ||
props.x2 = v | ||
props.y2 = percent(1) | ||
} else { | ||
props.y1 = v | ||
props.y2 = v | ||
props.x2 = percent(1) | ||
} | ||
if (d) { | ||
const offset = (type === "x" ? [d, 0] : [0, -d]).join(" ") | ||
props.transform = `translate(${offset})` | ||
} | ||
return $.line(props) | ||
} | ||
if (axis.hasSeries && type === "x") txtProps.dy = "3em" | ||
const children = axis.grid.map((t, i) => { | ||
const v = utils.toPrecision( | ||
axis.min + (axis.max - axis.min) * t, | ||
MAX_PRECISION | ||
) | ||
const v = toPrecision(axis.min + (axis.max - axis.min) * t, MAX_PRECISION) | ||
const lines = [] | ||
@@ -342,30 +368,6 @@ | ||
const altLProps = | ||
type === "x" | ||
? { | ||
x1: utils.percent(-altOffset), | ||
x2: utils.percent(-altOffset), | ||
} | ||
: { | ||
y1: utils.percent(-altOffset), | ||
y2: utils.percent(-altOffset), | ||
} | ||
const altRProps = | ||
type === "x" | ||
? { x1: utils.percent(altOffset), x2: utils.percent(altOffset) } | ||
: { y1: utils.percent(altOffset), y2: utils.percent(altOffset) } | ||
lines.push( | ||
(axis.inset || i > 0) && | ||
$.line({ | ||
class: "axis-line", | ||
...lineProps, | ||
...altLProps, | ||
}), | ||
i === axis.grid.length - 1 && | ||
$.line({ | ||
class: "axis-line", | ||
...lineProps, | ||
...altRProps, | ||
}) | ||
(axis.inset || t > 0) && | ||
line(-altOffset, "axis-line", t === 0 && 0.5), | ||
t === 1 && line(altOffset, "axis-line", -0.5) | ||
) | ||
@@ -375,6 +377,7 @@ } | ||
lines.push( | ||
$.line({ | ||
class: v == 0 ? "axis-base" : "axis-line", | ||
...lineProps, | ||
}) | ||
line( | ||
0, | ||
v == 0 ? "axis-base" : "axis-line", | ||
t === 0 ? 0.5 : t === 1 ? -0.5 : 0 | ||
) | ||
) | ||
@@ -386,4 +389,4 @@ } | ||
type === "x" | ||
? { x: utils.percent(pad(t, axis.inset)) } | ||
: { y: utils.percent(pad(1 - t, axis.inset)) } | ||
? { x: percent(pad(t, axis.inset)) } | ||
: { y: percent(pad(1 - t, axis.inset)) } | ||
)(lines) | ||
@@ -393,11 +396,5 @@ }) | ||
if (axis.spine) { | ||
const spineProps = { class: "axis-spine", ...lineProps } | ||
// Add an initial line if the first line is inset | ||
if (!axis.line(axis.min, 0, axis) || pad(axis.grid[0], axis.inset) > 0) { | ||
if (type === "x") { | ||
children.unshift($.line(spineProps)) | ||
} else { | ||
children.push($.line({ y1: "100%", y2: "100%", ...spineProps })) | ||
} | ||
children.unshift(line(0, "axis-spine", 0.5)) | ||
} | ||
@@ -410,7 +407,3 @@ | ||
) { | ||
if (type === "x") { | ||
children.push($.line({ x1: "100%", x2: "100%", ...spineProps })) | ||
} else { | ||
children.unshift($.line(spineProps)) | ||
} | ||
children.push(line(1, "axis-spine", -0.5)) | ||
} | ||
@@ -417,0 +410,0 @@ } |
import $ from "../lib/dom/index.js" | ||
import { get as getColor } from "../lib/color.js" | ||
import utils from "../lib/utils.js" | ||
import sum from "../lib/utils/sum.js" | ||
import percent from "../lib/utils/percent.js" | ||
import Map from "../lib/map.js" | ||
import legendTemplate from "./legend.js" | ||
import { default as axisTemplate, setup as setupAxis } from "./axis.js" | ||
import { max } from "../lib/utils/math.js" | ||
import wrap from "./wrap.js" | ||
@@ -116,4 +118,4 @@ | ||
const maxStack = Math.max(...data.flat(1).map((d) => d.length)) | ||
const maxSeries = Math.max(...data.map((d) => d.length)) | ||
const maxStack = max(...data.flat(1).map((d) => d.length)) | ||
const maxSeries = max(...data.map((d) => d.length)) | ||
@@ -158,3 +160,3 @@ // For unstacked charts, tally results rather than label. | ||
}, | ||
stack ? data.flat().map((d) => utils.sum(d)) : data.flat(2), | ||
stack ? data.flat().map((d) => sum(d)) : data.flat(2), | ||
{ minValue: 0.05 } | ||
@@ -165,4 +167,4 @@ ) | ||
const maxWidth = Math.max(...data.flat(2).map((d) => d.width)) | ||
const values = data.flat().map((d) => utils.sum(d)) | ||
const maxWidth = max(...data.flat(2).map((d) => d.width)) | ||
const values = data.flat().map((d) => sum(d)) | ||
@@ -196,6 +198,4 @@ xAxis = { | ||
$.svg({ | ||
x: data.length > 1 && utils.percent(axes.x.scale(k - 0.5)), | ||
width: utils.percent( | ||
data.length > 1 ? axes.x.scale(1) - axes.x.scale(0) : 1 | ||
), | ||
x: data.length > 1 && percent(axes.x.scale(k - 0.5)), | ||
width: percent(data.length > 1 ? axes.x.scale(1) - axes.x.scale(0) : 1), | ||
class: ["group", "group-" + k], | ||
@@ -208,8 +208,8 @@ })( | ||
const tally = map.tally(utils.sum(stack)) | ||
const tally = map.tally(sum(stack)) | ||
return $.svg({ | ||
class: ["series", "series-" + j], | ||
x: utils.percent(x), | ||
width: utils.percent(w), | ||
x: percent(x), | ||
width: percent(w), | ||
})([ | ||
@@ -221,11 +221,9 @@ ...stack.map((d, i) => { | ||
const h = axes.y.scale(d.value) | ||
const y = axes.y.scale( | ||
axes.y.max - utils.sum(stack.slice(0, i + 1)) | ||
) | ||
const y = axes.y.scale(axes.y.max - sum(stack.slice(0, i + 1))) | ||
const rect = $.rect({ | ||
x: utils.percent(-w / 2), | ||
y: utils.percent(y), | ||
height: utils.percent(h), | ||
width: utils.percent(w), | ||
x: percent(-w / 2), | ||
y: percent(y), | ||
height: percent(h), | ||
width: percent(w), | ||
fill: d.color[0], | ||
@@ -237,3 +235,3 @@ }) | ||
$.text({ | ||
y: utils.percent(y + h / 2), | ||
y: percent(y + h / 2), | ||
dy: "0.33em", | ||
@@ -250,3 +248,3 @@ color: d.color[1], | ||
$.text({ | ||
y: utils.percent(axes.y.scale(axes.y.max - utils.sum(stack))), | ||
y: percent(axes.y.scale(axes.y.max - sum(stack))), | ||
dy: "-0.5em", | ||
@@ -253,0 +251,0 @@ })(tally), |
@@ -16,3 +16,3 @@ import $ from "../lib/dom/index.js" | ||
if (type) { | ||
if (type && type !== "none") { | ||
symbol = [ | ||
@@ -19,0 +19,0 @@ includeLine && |
import $ from "../lib/dom/index.js" | ||
import utils from "../lib/utils.js" | ||
import percent from "../lib/utils/percent.js" | ||
import sum from "../lib/utils/sum.js" | ||
import interpolate from "../lib/utils/interpolate.js" | ||
import { isFinite } from "../lib/utils/math.js" | ||
import curve from "../lib/curve.js" | ||
@@ -38,14 +41,14 @@ import Map from "../lib/map.js" | ||
* @param {number[]} points | ||
* @param {function(Point, number): boolean} toPoint | ||
* @param {boolean} skip | ||
* @returns {number} ticks | ||
* @param {Axis} xAxis | ||
* @param {Axis} yAxis | ||
* @param {boolean} showGaps | ||
* @returns {string} path | ||
*/ | ||
const linePath = (points, toPoint, skip) => | ||
points | ||
.reduce((m, d, i) => { | ||
const linePath = (points, xAxis, yAxis, showGaps) => { | ||
let path = points | ||
.reduce((m, d) => { | ||
const l = m[m.length - 1] | ||
const p = toPoint(d, i) | ||
if (!p) { | ||
if (skip) { | ||
if (!(isFinite(d.x) && isFinite(d.y))) { | ||
if (showGaps) { | ||
return [...m, { curve: d.curve, points: [] }] | ||
@@ -57,2 +60,7 @@ } else { | ||
const p = [ | ||
SVGLINE_VIEWPORT_W * xAxis.scale(d.x), | ||
SVGLINE_VIEWPORT_H * (1 - yAxis.scale(d.y)), | ||
] | ||
if (l) l.points.push(p) | ||
@@ -68,14 +76,60 @@ | ||
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(l.curve)) { | ||
;[l.curve, ...args] = l.curve | ||
if (Array.isArray(type)) { | ||
;[type, ...args] = type | ||
} | ||
return curve[l.curve](l.points, ...args) | ||
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. | ||
@@ -99,3 +153,5 @@ * @alias module:shown.line | ||
* Points in the line with non-finite values are rendered as broken lines | ||
* where data is unavailable. Set to `false` to ignore missing values instead. | ||
* 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.sorted] - Whether to sort the values. | ||
* @returns {string} Rendered chart | ||
@@ -146,3 +202,2 @@ * | ||
*/ | ||
export default ({ | ||
@@ -156,2 +211,4 @@ data, | ||
yAxis, | ||
area = false, | ||
sorted = false, | ||
}) => { | ||
@@ -180,2 +237,40 @@ data = Array.isArray(data[0]) ? data : [data] | ||
if (sorted) { | ||
data.sort((al, bl) => { | ||
const a = sum(al) | ||
const b = sum(bl) | ||
return a === b ? 0 : a > b ? 1 : -1 | ||
}) | ||
} | ||
if (area) { | ||
// For lines with fewer points, continue along the baseline | ||
data.forEach((line, i) => { | ||
const curve = line.at(-1)?.curve | ||
while (line.length < maxLength) { | ||
line.push({ x: map.x({}, i, line.length), y: 0, ignore: true, 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 | ||
@@ -189,6 +284,28 @@ const axes = { | ||
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 = $.svg({ | ||
class: "lines", | ||
viewBox: `0 0 ${SVGLINE_VIEWPORT_W} ${SVGLINE_VIEWPORT_H}`, | ||
viewBox, | ||
preserveAspectRatio: "none", | ||
@@ -205,12 +322,3 @@ style: (axes.x.hasOverflow || axes.y.hasOverflow) && "overflow: hidden;", | ||
"fill": "none", | ||
"d": linePath( | ||
line, | ||
(d) => | ||
Number.isFinite(d.x) && | ||
Number.isFinite(d.y) && [ | ||
SVGLINE_VIEWPORT_W * axes.x.scale(d.x), | ||
SVGLINE_VIEWPORT_H * (1 - axes.y.scale(d.y)), | ||
], | ||
showGaps | ||
), | ||
"d": linePath(line, axes.x, axes.y, showGaps), | ||
}) | ||
@@ -227,14 +335,13 @@ ) | ||
$.svg({ | ||
"class": ["series", "series-" + j], | ||
"text-anchor": "middle", | ||
"color": data[0]?.color[0], | ||
class: ["series", "series-" + j], | ||
color: data[0]?.color[0], | ||
})( | ||
data.map((d) => { | ||
return ( | ||
Number.isFinite(d.x) && | ||
Number.isFinite(d.y) && | ||
isFinite(d.x) && | ||
isFinite(d.y) && | ||
d.shape && | ||
$.use({ | ||
x: utils.percent(axes.x.scale(d.x)), | ||
y: utils.percent(1 - axes.y.scale(d.y)), | ||
x: percent(axes.x.scale(d.x)), | ||
y: percent(1 - axes.y.scale(d.y)), | ||
href: `#symbol-${d.shape}`, | ||
@@ -259,3 +366,3 @@ width: "1em", | ||
"chart", | ||
"chart-line", | ||
area ? "chart-area" : "chart-line", | ||
axes.x.label && "has-xaxis xaxis-w" + axes.x.width, | ||
@@ -282,2 +389,3 @@ axes.y.label && "has-yaxis yaxis-w" + axes.y.width, | ||
axisX, | ||
areas, | ||
lines, | ||
@@ -284,0 +392,0 @@ symbols, |
import $ from "../lib/dom/index.js" | ||
import utils from "../lib/utils.js" | ||
import percent from "../lib/utils/percent.js" | ||
import sum from "../lib/utils/sum.js" | ||
import toPrecision from "../lib/utils/to-precision.js" | ||
import Map from "../lib/map.js" | ||
import wrap from "./wrap.js" | ||
import legendTemplate from "./legend.js" | ||
import { min, max, tau, cos, sin } from "../lib/utils/math.js" | ||
const tau = Math.PI * 2 | ||
const arc = (t, r) => percent((t % 1) * tau * r) | ||
const arc = (t, r) => utils.percent((t % 1) * tau * r) | ||
/** | ||
@@ -27,9 +28,9 @@ * Calculate the bounds based on the portion of the circle | ||
const xs = ts.map((t) => utils.toPrecision(Math.cos(t * tau) / 2)) | ||
const ys = ts.map((t) => utils.toPrecision(Math.sin(t * tau) / 2)) | ||
const xs = ts.map((t) => toPrecision(cos(t * tau) / 2)) | ||
const ys = ts.map((t) => toPrecision(sin(t * tau) / 2)) | ||
const maxX = Math.max(...xs) | ||
const minX = Math.min(...xs) | ||
const maxY = Math.max(...ys) | ||
const minY = Math.min(...ys) | ||
const maxX = max(...xs) | ||
const minX = min(...xs) | ||
const maxY = max(...ys) | ||
const minY = min(...ys) | ||
@@ -111,3 +112,3 @@ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY } | ||
const total = utils.sum(data) | ||
const total = sum(data) | ||
const scale = endAngle - startAngle | ||
@@ -117,3 +118,3 @@ | ||
const t = (d.value / total) * scale | ||
const o = startAngle + (utils.sum(data.slice(0, i)) / total) * scale | ||
const o = startAngle + (sum(data.slice(0, i)) / total) * scale | ||
@@ -127,8 +128,8 @@ const radius = (1 - d.width / 2) / 2 | ||
const x = Math.cos(theta) * shift * radius | ||
const y = Math.sin(theta) * shift * radius | ||
const x = cos(theta) * shift * radius | ||
const y = sin(theta) * shift * radius | ||
return $.g({ | ||
"class": `segment segment-${i}`, | ||
"aria-label": `${d.label} (${utils.percent(t)})`, | ||
"aria-label": `${d.label} (${percent(t)})`, | ||
"attrs": d.attrs, | ||
@@ -142,7 +143,7 @@ })([ | ||
"role": "presentation", | ||
"r": utils.percent(radius), | ||
"r": percent(radius), | ||
"stroke": d.color[0], | ||
"stroke-dasharray": dasharray, | ||
"stroke-dashoffset": dashoffset, | ||
"stroke-width": utils.percent(d.width / 2), | ||
"stroke-width": percent(d.width / 2), | ||
"fill": "none", | ||
@@ -154,4 +155,4 @@ }) | ||
class: "segment-label", | ||
x: utils.percent(x), | ||
y: utils.percent(y), | ||
x: percent(x), | ||
y: percent(y), | ||
dy: "0.33em", | ||
@@ -183,6 +184,6 @@ role: "presentation", | ||
$.svg({ | ||
"x": utils.percent(0.5 - (bounds.x + bounds.w / 2) / bounds.w), | ||
"y": utils.percent(0.5 - (bounds.y + bounds.h / 2) / bounds.h), | ||
"width": utils.percent(1 / bounds.w), | ||
"height": utils.percent(1 / bounds.h), | ||
"x": percent(0.5 - (bounds.x + bounds.w / 2) / bounds.w), | ||
"y": percent(0.5 - (bounds.y + bounds.h / 2) / bounds.h), | ||
"width": percent(1 / bounds.w), | ||
"height": percent(1 / bounds.h), | ||
"role": "presentation", | ||
@@ -189,0 +190,0 @@ "text-anchor": "middle", |
import $ from "../lib/dom/index.js" | ||
import utils from "../lib/utils.js" | ||
import percent from "../lib/utils/percent.js" | ||
import Map from "../lib/map.js" | ||
@@ -102,4 +102,4 @@ import legendTemplate from "./legend.js" | ||
$.use({ | ||
x: utils.percent(axes.x.scale(d.x)), | ||
y: utils.percent(1 - axes.y.scale(d.y)), | ||
x: percent(axes.x.scale(d.x)), | ||
y: percent(1 - axes.y.scale(d.y)), | ||
href: `#symbol-${d.shape}`, | ||
@@ -106,0 +106,0 @@ width: `${d.r}em`, |
@@ -26,3 +26,3 @@ import $ from "../lib/dom/index.js" | ||
symbol = $.path({ | ||
d: "M0-5L5,0L0,5L-5,0Z", | ||
d: "M0-5.5L5.5,0L0,5.5L-5.5,0Z", | ||
}) | ||
@@ -29,0 +29,0 @@ } |
declare module "shown" { | ||
/** | ||
* Generate an area chart. | ||
* @example | ||
* shown.area({ | ||
* title: "Stacked area chart", | ||
* data: [ | ||
* [52.86, 20.65, 14.54, 10.09, 8.41], | ||
* [21.97, 31.71, 6.31, 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: "monotone", | ||
* key: ["α", "β", "γ", "δ"], | ||
* }, | ||
* sorted: true, | ||
* }) | ||
* @example | ||
* shown.area({ | ||
* title: "Discontinuous data is interpolated", | ||
* data: [ | ||
* [ 12.2, 19.2, 35.9, 88.1, 12.8, 48.2, ], | ||
* [ 25.7, 10.1, 48.5, 84.4, 39.6, ], | ||
* [ 11.0, 43.5, 68.4, 79.6, null, null, 35.4 ], | ||
* [ 20.3, null, 17.5, 71.6, 67.1, 64.1, 25.4 ], | ||
* ], | ||
* map: { | ||
* key: ["A", "B", "C", "D"], | ||
* }, | ||
* }) | ||
* @param options - Data and display options for the chart. Area charts | ||
* are a wrapper for line charts, with the default options for `sorted` and | ||
* `area` set to true. | ||
* @param 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 [options.title] - The title for this chart, set to the | ||
* `<title>` element for better accessibility. | ||
* @param [options.description] - The description for this chart, set | ||
* to the `<desc>` element for better accessibility. | ||
* @param [options.map] - Controls for transforming data. See {@link MapOptions} for more details. | ||
* @param [options.xAxis] - Overrides for the x-axis. See {@link AxisOptions} for more details. | ||
* @param [options.yAxis] - Overrides for the y-axis. See {@link AxisOptions} for more details. | ||
* @param [options.sorted] - Whether to sort the values. | ||
* @returns Rendered chart | ||
*/ | ||
function area(options: { | ||
data: any[]; | ||
title?: string; | ||
description?: string; | ||
map?: MapOptions; | ||
xAxis?: AxisOptions; | ||
yAxis?: AxisOptions; | ||
sorted?: boolean; | ||
}): string; | ||
/** | ||
* Generate a bar chart. | ||
@@ -171,3 +226,5 @@ * @example | ||
* @param [options.showGaps] - Points in the line with non-finite values are rendered as broken lines | ||
* where data is unavailable. Set to `false` to ignore missing values instead. | ||
* where data is unavailable. Set to `false` to skip missing values instead. | ||
* @param [options.area] - Render the line chart as an area chart. | ||
* @param [options.sorted] - Whether to sort the values. | ||
* @returns Rendered chart | ||
@@ -183,2 +240,4 @@ */ | ||
showGaps?: boolean; | ||
area?: boolean; | ||
sorted?: boolean; | ||
}): string; | ||
@@ -320,7 +379,7 @@ /** | ||
* The default function returns the _index_ of the item. | ||
* **Line and Scatter Chart only** | ||
* **Line, Area and Scatter Chart only** | ||
* @property [y] - Parse the y-axis value from the data. This function is useful if your | ||
* data structure wraps each value in an object. | ||
* The default function returns the _value_ of the item. | ||
* **Line and Scatter Chart only** | ||
* **Line, Area and Scatter Chart only** | ||
* @property [r] - Parse the radial size from the data. This function is useful if you want to | ||
@@ -355,3 +414,4 @@ * visualise another dimension in the data. If the radius is not greater | ||
* function is useful if you want to override or add arbitrary attributes on the | ||
* chart. | ||
* chart. For example, you could add a `data-tooltip` attribute to trigger | ||
* tooltips using a JavaScript library. | ||
*/ | ||
@@ -358,0 +418,0 @@ declare type MapOptions = { |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
139015
36
4531
10