UNPKG

apexcharts

Version:

A JavaScript Chart Library

370 lines (344 loc) 12.9 kB
// @ts-check /** * The three layouts that are not outlines: concentric rings, a sphere, and a * triangular lattice. * * Each of these could be written as a silhouette (a disc, a disc, a triangle) * and each would be worse for it. A target wants dots ON rings so it reads as a * target rather than as the built-in packed blob; a globe wants dots on a * sphere, with the projection doing the curving; a pyramid wants tiers that are * real counts, with the dots cutting the slope. Visual quality over abstraction * purity: they share the allocation, ordering and radius fitting, and nothing * else. * * @module unit-shapes/engine/radial */ import { allocate, fitRadius, assign } from './pack.js' import { defineShape } from './shape.js' /** @typedef {import('./shape.js').ShapeMeta} ShapeMeta */ /** @typedef {import('./shape.js').UnitShape} UnitShape */ /** @typedef {import('./shape.js').UnitLayout} UnitLayout */ /** @typedef {import('./pack.js').Slot} Slot */ /** @param {number[]} w */ const sum = (w) => w.reduce((a, b) => a + b, 0) /** * Concentric rings: a target. * * Ring gap and in-ring gap are the same number, so density is even instead of * piling up in the middle, and each ring is turned by the golden angle. That * last part matters: rings hold about 2*PI*k dots, so a rational turn (none, or * half a step) lines neighbouring rings up and the eye picks out spokes and * moire. * * @param {ShapeMeta} meta * @returns {UnitLayout} */ function buildRings(meta) { const padding = meta.padding == null ? 0.94 : meta.padding const inward = meta.order === 'centerIn' const twist = meta.twist == null ? 2.399963 : meta.twist return (objects, rect) => { const n = objects.length if (!n) return [] const radius = (Math.min(rect.width, rect.height) / 2) * padding const cx = rect.x + rect.width / 2 const cy = rect.y + rect.height / 2 // Ring k sits at (k + 0.5) gaps out, so at that same gap it holds about // 2*PI*(k + 0.5) dots. Grow the ring count until they hold n. /** @param {number} k */ const weightsFor = (k) => { /** @type {number[]} */ const w = [] for (let i = 0; i < k; i++) { w.push(Math.max(1, Math.round(2 * Math.PI * (i + 0.5)))) } return w } let count = 1 while (count < 400 && sum(weightsFor(count)) < n) count++ const weights = weightsFor(count) const gap = radius / count const per = allocate(weights, n) const r = fitRadius(objects[0].r > 0 ? objects[0].r : 3, gap) /** @type {Slot[]} */ const slots = [] for (let k = 0; k < count; k++) { const ring = inward ? count - 1 - k : k const rr = (ring + 0.5) * gap const m = per[ring] const phase = twist * ring for (let i = 0; i < m; i++) { const t = phase + (i / m) * 2 * Math.PI slots.push({ x: cx + rr * Math.cos(t), y: cy + rr * Math.sin(t), r, row: k, }) } } return assign(objects, slots, 'rows', gap) } } /** * A sphere: latitude bands on the hemisphere facing the viewer. * * The 3D read comes from three things, none of which is a drawing: rows converge * towards the pole, each row bows because the pole leans in, and dots fade * smaller as the surface turns away. * * What it deliberately does NOT do is space dots evenly in LONGITUDE, which is * the obvious way to write this and is wrong. Screen spacing along a latitude * falls off quadratically towards the limb, so uniform longitude piles rim dots * on top of each other (measured: neighbours at 0.7% of their radii at 3000 * units, i.e. drawn on top of one another, while still looking dense enough to * pass the eye). Instead: * * - dots are spread evenly in SCREEN x across each row, so no rim crowding, * - latitude bands closer together than one pitch are dropped, so no polar * pile-up either, which is the same failure rotated 90 degrees, * - the pitch itself is bisected, exactly as the silhouette packer does it, so * density follows the dot count. * * @param {ShapeMeta} meta * @returns {UnitLayout} */ function buildGlobe(meta) { const padding = meta.padding == null ? 0.94 : meta.padding const tilt = ((meta.tilt == null ? 15 : meta.tilt) * Math.PI) / 180 const order = meta.order || 'rows' return (objects, rect) => { const n = objects.length if (!n) return [] const radius = (Math.min(rect.width, rect.height) / 2) * padding const cx = rect.x + rect.width / 2 const cy = rect.y + rect.height / 2 // Positive tilt leans the top pole towards the viewer. const cosT = Math.cos(tilt) const sinT = Math.sin(tilt) const SAMPLES = 40 /** * Project a point on the sphere. The lean rotates the sphere, so `depth` * (the z of the rotated point) is what decides visibility and shading. * @param {number} sinPhi @param {number} cosPhi @param {number} lon */ const project = (sinPhi, cosPhi, lon) => { const z = cosPhi * Math.cos(lon) return { x: radius * cosPhi * Math.sin(lon), y: radius * (sinPhi * cosT + z * sinT), depth: z * cosT - sinPhi * sinT, } } /** * Candidate dots at a given pitch: latitude rows, walked at one pitch of * SCREEN arc length, then filtered so no dot lands within a pitch of one * already taken. * * Two things here are easy to get wrong and both show as damage. First, a * leaning sphere does NOT show longitudes -90..90: the visible range widens * towards the near pole (whose rings become complete loops) and closes * towards the far one, so sampling the un-leaned half leaves a bald crescent * along one rim. Second, spacing has to be measured on SCREEN, not in * longitude, because longitudinal steps foreshorten to nothing at the limb. * * The greedy filter is the interesting part. Rows near the visible pole nest * inside each other, so a row's neighbour on screen is not its neighbour in * latitude, and no amount of comparing consecutive rows will prove they stay * apart (an earlier attempt at exactly that still put two polar dots 2px * apart). Enforcing separation directly is both simpler and airtight: it * thins the polar cap and the limb by construction, and it leaves the * graticule visible everywhere it is not crowded. * * @param {number} pitch */ const candidatesAt = (pitch) => { const count = Math.max(3, Math.ceil((Math.PI * radius) / pitch)) const dLat = Math.PI / count const near = pitch * 0.95 const cell = near /** @type {Map<string, {x:number,y:number}[]>} */ const grid = new Map() /** @type {{x:number,y:number,depth:number}[][]} */ const rows = [] let total = 0 const keep = (/** @type {{x:number,y:number}} */ p) => { const gx = Math.floor(p.x / cell) const gy = Math.floor(p.y / cell) for (let a = -1; a <= 1; a++) { for (let b = -1; b <= 1; b++) { const bucket = grid.get(`${gx + a},${gy + b}`) if (!bucket) continue for (let i = 0; i < bucket.length; i++) { if (Math.hypot(bucket[i].x - p.x, bucket[i].y - p.y) < near) { return false } } } } const key = `${gx},${gy}` const own = grid.get(key) if (own) own.push(p) else grid.set(key, [p]) return true } for (let b = 0; b < count; b++) { const phi = -Math.PI / 2 + (b + 0.5) * dLat const cosPhi = Math.cos(phi) const sinPhi = Math.sin(phi) // Visible longitudes: depth >= 0. const cut = (sinPhi * sinT) / (cosPhi * cosT || 1e-9) if (cut >= 1) continue const lonMax = cut <= -1 ? Math.PI : Math.acos(cut) const closed = lonMax >= Math.PI - 1e-9 /** @type {{x:number,y:number,depth:number}[]} */ const pts = [] /** @type {number[]} */ const cum = [0] for (let k = 0; k <= SAMPLES; k++) { const lon = -lonMax + (2 * lonMax * k) / SAMPLES const p = project(sinPhi, cosPhi, lon) pts.push(p) if (k > 0) { const q = pts[k - 1] cum.push(cum[k - 1] + Math.hypot(p.x - q.x, p.y - q.y)) } } const len = cum[cum.length - 1] const inset = closed ? 0 : Math.min(pitch * 0.42, len / 2) const span = len - 2 * inset let m if (closed) m = Math.max(1, Math.round(len / pitch)) else if (span > 0) m = Math.floor(span / pitch) + 1 else m = len >= pitch * 0.45 ? 1 : 0 if (!m) continue const step = closed ? len / m : m > 1 ? span / (m - 1) : 0 /** @type {{x:number,y:number,depth:number}[]} */ const row = [] let seg = 0 for (let j = 0; j < m; j++) { const s = closed ? j * step : m > 1 ? inset + j * step : len / 2 while (seg < cum.length - 2 && cum[seg + 1] < s) seg++ const c0 = cum[seg] const c1 = cum[seg + 1] const t = c1 > c0 ? (s - c0) / (c1 - c0) : 0 const p0 = pts[seg] const p1 = pts[seg + 1] const p = { x: cx + p0.x + (p1.x - p0.x) * t, y: cy + p0.y + (p1.y - p0.y) * t, depth: p0.depth + (p1.depth - p0.depth) * t, } if (keep(p)) row.push(p) } if (!row.length) continue rows.push(row) total += row.length } return { rows, total } } // Largest pitch whose surviving candidates still hold every dot. let lo = 1 let hi = 2 * radius for (let i = 0; i < 30; i++) { const mid = (lo + hi) / 2 if (candidatesAt(mid).total >= n) lo = mid else hi = mid } const pitch = lo const { rows } = candidatesAt(pitch) const per = allocate( rows.map((r) => r.length), n, ) const baseR = fitRadius(objects[0].r > 0 ? objects[0].r : 3, pitch) /** @type {Slot[]} */ const slots = [] rows.forEach((row, i) => { const take = per[i] if (!take) return // Thin each row EVENLY rather than truncating it, so trimming to the exact // dot count keeps the sphere covered instead of emptying its last rows. for (let j = 0; j < take; j++) { const p = row[Math.min(row.length - 1, Math.floor((j * row.length) / take))] slots.push({ x: p.x, y: p.y, // Shading, not spacing: the surface turning away reads as smaller // dots. Spacing is already even, so this cannot open a gap. r: baseR * (0.62 + 0.38 * Math.sqrt(Math.max(0, p.depth))), row: i, }) } }) slots.sort((a, b) => a.y - b.y || a.x - b.x) return assign(objects, slots, order, pitch) } } /** * A triangular lattice: a pyramid. * * Tier t holds t + 1 dots, so the dots themselves cut the slope and every tier * is a real count rather than a clipped rectangle. Filled from the base, a head * count turns into the hierarchy it describes. * * @param {ShapeMeta} meta * @returns {UnitLayout} */ function buildTiers(meta) { const padding = meta.padding == null ? 0.94 : meta.padding const rowRatio = meta.rowRatio == null ? 0.9 : meta.rowRatio const order = meta.order || 'rowsUp' return (objects, rect) => { const n = objects.length if (!n) return [] const tiers = Math.max(1, Math.round((Math.sqrt(8 * n + 1) - 1) / 2)) /** @type {number[]} */ const weights = [] for (let t = 0; t < tiers; t++) weights.push(t + 1) const per = allocate(weights, n) const widest = Math.max(...per) const dx = Math.min( (rect.width * padding) / Math.max(1, widest - 1 + 1.6), (rect.height * padding) / (tiers * rowRatio), ) const dy = dx * rowRatio const cx = rect.x + rect.width / 2 const top = rect.y + (rect.height - tiers * dy) / 2 const r = fitRadius(objects[0].r > 0 ? objects[0].r : 3, dx) /** @type {Slot[]} */ const slots = [] for (let i = 0; i < tiers; i++) { for (let j = 0; j < per[i]; j++) { slots.push({ x: cx + (j - (per[i] - 1) / 2) * dx, y: top + (i + 0.5) * dy, r, row: i, }) } } return assign(objects, slots, order, dx) } } /** * @param {ShapeMeta} meta * @returns {UnitShape} */ export function rings(meta) { return defineShape({ ...meta, kind: 'rings' }, buildRings) } /** * @param {ShapeMeta} meta * @returns {UnitShape} */ export function sphere(meta) { return defineShape({ ...meta, kind: 'globe' }, buildGlobe) } /** * @param {ShapeMeta} meta * @returns {UnitShape} */ export function tiers(meta) { return defineShape({ ...meta, kind: 'tiers' }, buildTiers) }