apexcharts
Version:
A JavaScript Chart Library
173 lines (160 loc) • 6.01 kB
JavaScript
// @ts-check
/**
* Render a shape to an SVG string, with no chart and no DOM.
*
* A shape is a pure function of marks and a rectangle, so a picture of one needs
* neither a chart instance nor a browser. That is what makes this useful: docs
* galleries, a shape picker, README images and launch graphics can all be built at
* build time, or on a server, from the catalog alone.
*
* @module unit-shapes/engine/preview
*/
import { allocate } from './pack.js'
/** @typedef {import('./shape.js').UnitShape} UnitShape */
/**
* The library's default series colours (`palette1`).
*
* Duplicated from `src/utils/ThemePalettes.js` rather than imported: this bundle
* is meant to stay standalone, and that module hands back every palette at once.
* A test asserts the two never drift apart.
* @type {string[]}
*/
const PALETTE = ['#008FFB', '#00A86F', '#CA8501', '#FF4560', '#846DD5']
/**
* @param {UnitShape} shape
* @param {object} [opts]
* @param {number} [opts.count] dots to pack (defaults to the total of `series`,
* else the shape's minUnits, else 220)
* @param {number[]} [opts.series] series values, coloured band by band the way the
* chart colours them. Pass this whenever the preview stands in for a real chart:
* a shape drawn in one flat colour says the data cannot be told apart, which is
* the opposite of what a unit chart does.
* @param {number} [opts.width] viewport width (300)
* @param {number} [opts.height] viewport height (defaults to width)
* @param {number} [opts.padding] rect inset in px (0)
* @param {string | string[]} [opts.fill] one colour, or a ramp. With `series` a
* ramp is one colour per series (defaulting to the library's own palette);
* without it, a ramp is handed out in equal parts along the fill order.
* @param {number} [opts.r] override the dot radius the shape suggests
* @param {boolean} [opts.svg] wrap in an <svg> element (true); false returns the
* dots only, for embedding in a viewport the caller owns
* @returns {string}
*/
export function preview(shape, opts = {}) {
const series = opts.series && opts.series.length ? opts.series : null
const total = series ? series.reduce((a, b) => a + Math.max(0, b), 0) : 0
const count = Math.max(
1,
Math.round(opts.count || total || shape.shape.minUnits || 220),
)
const width = opts.width || 300
const height = opts.height || width
const pad = opts.padding || 0
const given = Array.isArray(opts.fill)
? opts.fill
: opts.fill
? [opts.fill]
: null
const fills = given || (series ? PALETTE : ['#008FFB'])
// Dots per series at THIS dot count: the same proportional split the chart
// makes when it scales a dataset down to maxUnits, so the bands a preview
// shows are the bands the chart would draw.
const shares = series ? share(series, count) : null
/** @type {import('./shape.js').UnitObject[]} */
const objects = []
/** @type {number[]} the series each dot belongs to, in fill order */
const owner = []
const r = opts.r || Math.max(1.2, Math.sqrt((width * height) / count) / 2.6)
for (let i = 0; i < count; i++) {
const s = shares ? seriesAt(shares, i) : 0
owner.push(s)
objects.push({
id: `p:${i}`,
index: i,
seriesIndex: s,
dataPointIndex: i,
label: shares ? `series-${s + 1}` : 'preview',
value: 1,
datum: undefined,
// The shapes refine this themselves; it only has to be in the right league.
r,
})
}
const placed = shape(objects, {
x: pad,
y: pad,
width: Math.max(1, width - pad * 2),
height: Math.max(1, height - pad * 2),
})
// Group by fill so a ramp emits one <g> per colour instead of a fill attribute
// per circle, which roughly halves the markup for a multi-colour preview.
/** @type {Map<string, string[]>} */
const groups = new Map()
placed.forEach((p, i) => {
// With series, a dot's colour is its series, exactly as in the chart. Without
// one, a ramp is spread evenly along the shape's own fill order.
const slot = shares
? owner[i] % fills.length
: Math.floor((i / placed.length) * fills.length)
const fill = fills[slot] || fills[0]
const circle = `<circle cx="${round(p.x)}" cy="${round(p.y)}" r="${round(p.r || 3)}"/>`
const list = groups.get(fill)
if (list) list.push(circle)
else groups.set(fill, [circle])
})
let body = ''
groups.forEach((circles, fill) => {
body += `<g fill="${fill}">${circles.join('')}</g>`
})
if (opts.svg === false) return body
return (
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" ` +
`width="${width}" height="${height}" role="img" ` +
`aria-label="${shape.shape.name} drawn with ${placed.length} dots">` +
`${body}</svg>`
)
}
/**
* Series values -> dots per series, summing to exactly `count`.
*
* Largest remainder, then the same promise the chart makes when it clips a
* dataset to `maxUnits`: a series with a value keeps at least one dot. A band
* that rounded away would read as a category that is not in the data at all.
*
* @param {number[]} series
* @param {number} count
* @returns {number[]}
*/
function share(series, count) {
const weights = series.map((v) => Math.max(0, v))
const out = allocate(weights, count)
for (let i = 0; i < out.length; i++) {
if (out[i] || !weights[i]) continue
// Borrow from whoever has most, so the total stays exact.
let big = 0
for (let j = 1; j < out.length; j++) if (out[j] > out[big]) big = j
if (out[big] > 1) {
out[big]--
out[i] = 1
}
}
return out
}
/**
* Which series owns dot `i`, given the per-series dot counts.
* @param {number[]} shares
* @param {number} i
* @returns {number}
*/
function seriesAt(shares, i) {
let acc = 0
for (let s = 0; s < shares.length; s++) {
acc += shares[s]
if (i < acc) return s
}
return shares.length - 1
}
/** @param {number} n */
function round(n) {
return Math.round(n * 10) / 10
}