apexcharts
Version:
A JavaScript Chart Library
314 lines (293 loc) • 11.6 kB
JavaScript
// @ts-check
/**
* A region is the packer's ONLY view of a shape: given a horizontal line, which
* x-intervals are inside?
*
* spansAt(y) => [{ x0, x1 }, ...]
*
* Everything downstream of that answer (fitting the dot gap, splitting dots
* across rows, ordering, radius) is shape-agnostic, so a new kind of shape is a
* new region rather than a new layout. That is the seam a stroke region will use
* to cover thin glyphs (a checkmark, an arrow): the interior of a stroked
* centreline is a union of capsules, whose row intersections are closed-form, so
* it plugs in here without any new placement code.
*
* @module unit-shapes/engine/region
*/
/** @typedef {import('./path.js').Point} Point */
/** @typedef {{ x0: number, x1: number }} Span */
/**
* @typedef {object} Region
* @property {number} minY
* @property {number} maxY
* @property {number} minX
* @property {number} maxX
* @property {(y: number) => Span[]} spansAt
*/
const BANDS = 128
/**
* Interior intervals of a polygon set along the line y, in PIXEL space (the
* transform is baked in when the region is built, so the packer never converts
* coordinates).
*
* Nonzero winding is the default because it is what makes shapes composable:
* overlapping subpaths union (a tree canopy can simply overlap its trunk) and a
* subpath wound the other way punches a hole (the door of a house, the porthole
* of a rocket). Even-odd is available for outlines authored that way.
*
* @param {Point[][]} polys in path units
* @param {{ scale: number, offX: number, offY: number }} tf
* @param {{ evenOdd?: boolean }} [opts]
* @returns {Region}
*/
export function polygonRegion(polys, tf, opts = {}) {
const evenOdd = !!opts.evenOdd
/** @type {{x0:number,y0:number,x1:number,y1:number}[]} */
const edges = []
let minY = Infinity
let maxY = -Infinity
let minX = Infinity
let maxX = -Infinity
polys.forEach((pts) => {
for (let i = 0; i < pts.length; i++) {
const a = pts[i]
const b = pts[(i + 1) % pts.length]
const ax = tf.offX + a.x * tf.scale
const ay = tf.offY + a.y * tf.scale
const bx = tf.offX + b.x * tf.scale
const by = tf.offY + b.y * tf.scale
if (ax < minX) minX = ax
if (bx < minX) minX = bx
if (ax > maxX) maxX = ax
if (bx > maxX) maxX = bx
if (ay < minY) minY = ay
if (by < minY) minY = by
if (ay > maxY) maxY = ay
if (by > maxY) maxY = by
// A horizontal edge can neither open nor close a span.
if (ay === by) continue
edges.push({ x0: ax, y0: ay, x1: bx, y1: by })
}
})
if (!edges.length) {
return { minY: 0, maxY: 0, minX: 0, maxX: 0, spansAt: () => [] }
}
// Bucket edges by y so a row query touches a handful of them rather than all:
// fitting the gap re-scans every row ~34 times, which is where the cost is.
const height = Math.max(1e-6, maxY - minY)
/** @type {{x0:number,y0:number,x1:number,y1:number}[][]} */
const bands = []
for (let k = 0; k < BANDS; k++) bands.push([])
edges.forEach((e) => {
const lo = Math.min(e.y0, e.y1)
const hi = Math.max(e.y0, e.y1)
let i0 = Math.floor(((lo - minY) / height) * BANDS)
let i1 = Math.floor(((hi - minY) / height) * BANDS)
i0 = Math.max(0, Math.min(BANDS - 1, i0))
i1 = Math.max(0, Math.min(BANDS - 1, i1))
for (let i = i0; i <= i1; i++) bands[i].push(e)
})
/** @type {(y: number) => Span[]} */
const spansAt = (y) => {
const bi = Math.floor(((y - minY) / height) * BANDS)
if (bi < 0 || bi > BANDS - 1) return []
const list = bands[bi]
/** @type {{x:number, dir:number}[]} */
const xs = []
for (let i = 0; i < list.length; i++) {
const e = list[i]
const down = e.y1 > e.y0
const lo = down ? e.y0 : e.y1
const hi = down ? e.y1 : e.y0
// Half-open in y, so a vertex shared by two edges is counted once.
if (y < lo || y >= hi) continue
xs.push({
x: e.x0 + ((y - e.y0) / (e.y1 - e.y0)) * (e.x1 - e.x0),
dir: down ? 1 : -1,
})
}
if (xs.length < 2) return []
xs.sort((a, b) => a.x - b.x)
/** @type {Span[]} */
const spans = []
if (evenOdd) {
for (let j = 0; j + 1 < xs.length; j += 2) {
spans.push({ x0: xs[j].x, x1: xs[j + 1].x })
}
return spans
}
let wind = 0
let start = 0
for (let k = 0; k < xs.length; k++) {
const was = wind
wind += xs[k].dir
if (was === 0 && wind !== 0) start = xs[k].x
else if (was !== 0 && wind === 0) spans.push({ x0: start, x1: xs[k].x })
}
return spans
}
return { minY, maxY, minX, maxX, spansAt }
}
/**
* Interior intervals of a STROKED centreline: the set of points within
* `halfWidth` of the path.
*
* This is the region that lets thin glyphs into the collection at all. A
* checkmark, an arrow, a wifi arc and a heartbeat trace have no interior to fill,
* only a line and a thickness, so as outlines they would have to be authored as
* closed polygons with both sides of every segment and every join drawn by hand.
*
* Why a region and not a layout. The tempting design is to walk the centreline
* placing dots along it like beads, and it fails on capacity: a line of length L
* holds L/pitch beads and no more, so a high dot count has to spill into parallel
* lanes, at which point it has become area packing written a second time. Treating
* the stroke as a region instead means the existing packer bisects the gap as
* always, and the same glyph is two dots thick at 100 units and ten dots thick at
* 3000, with no ceiling and no second code path.
*
* The geometry is closed-form, which is what keeps this small. A stroked segment
* is a capsule: a rotated rectangle plus a disc at each end. A horizontal line
* meets the rectangle in one interval (found from its edge crossings) and each
* disc in one interval (found from the circle equation), and the union of the
* intervals is the answer. Round caps and round joins come free from the discs,
* and a centreline that crosses itself simply unions with itself, so there is no
* join, cap or self-intersection case to special case.
*
* A closed centreline needs no wrap-around here: `flattenPath` appends the start
* point for `Z`, so the closing segment is already in the point list.
*
* @param {Point[][]} polys centrelines in path units, from
* `flattenPath(d, tol, true)` so straight two-point glyphs survive
* @param {{ scale: number, offX: number, offY: number }} tf
* @param {number} halfWidth in PATH units; scaled with the shape
* @returns {Region}
*/
export function strokeRegion(polys, tf, halfWidth) {
const r = Math.max(1e-6, halfWidth * tf.scale)
/** @type {{ax:number,ay:number,bx:number,by:number,nx:number,ny:number}[]} */
const caps = []
let minY = Infinity
let maxY = -Infinity
let minX = Infinity
let maxX = -Infinity
polys.forEach((pts) => {
for (let i = 0; i + 1 < pts.length; i++) {
const ax = tf.offX + pts[i].x * tf.scale
const ay = tf.offY + pts[i].y * tf.scale
const bx = tf.offX + pts[i + 1].x * tf.scale
const by = tf.offY + pts[i + 1].y * tf.scale
const len = Math.hypot(bx - ax, by - ay)
// A zero-length segment contributes only its disc, which the endpoints
// already cover, so the normal can be anything.
const nx = len > 1e-9 ? (-(by - ay) / len) * r : r
const ny = len > 1e-9 ? ((bx - ax) / len) * r : 0
caps.push({ ax, ay, bx, by, nx, ny })
if (Math.min(ax, bx) - r < minX) minX = Math.min(ax, bx) - r
if (Math.max(ax, bx) + r > maxX) maxX = Math.max(ax, bx) + r
if (Math.min(ay, by) - r < minY) minY = Math.min(ay, by) - r
if (Math.max(ay, by) + r > maxY) maxY = Math.max(ay, by) + r
}
})
if (!caps.length) {
return { minY: 0, maxY: 0, minX: 0, maxX: 0, spansAt: () => [] }
}
// Same banding as the polygon region, and for the same reason: fitting the gap
// rescans every row ~34 times, so a row query must not touch every capsule.
const height = Math.max(1e-6, maxY - minY)
/** @type {typeof caps[]} */
const bands = []
for (let k = 0; k < BANDS; k++) bands.push([])
caps.forEach((c) => {
const lo = Math.min(c.ay, c.by) - r
const hi = Math.max(c.ay, c.by) + r
let i0 = Math.floor(((lo - minY) / height) * BANDS)
let i1 = Math.floor(((hi - minY) / height) * BANDS)
i0 = Math.max(0, Math.min(BANDS - 1, i0))
i1 = Math.max(0, Math.min(BANDS - 1, i1))
for (let i = i0; i <= i1; i++) bands[i].push(c)
})
/**
* Interval where the row meets a disc, or null.
* @param {number} cx @param {number} cy @param {number} y
* @returns {Span | null}
*/
const disc = (cx, cy, y) => {
const dy = y - cy
if (dy <= -r || dy >= r) return null
const half = Math.sqrt(r * r - dy * dy)
return { x0: cx - half, x1: cx + half }
}
/** @type {(y: number) => Span[]} */
const spansAt = (y) => {
const bi = Math.floor(((y - minY) / height) * BANDS)
if (bi < 0 || bi > BANDS - 1) return []
const list = bands[bi]
/** @type {Span[]} */
const parts = []
for (let i = 0; i < list.length; i++) {
const c = list[i]
// The rotated rectangle: four corners, offset either side of the segment.
const qx = [c.ax + c.nx, c.bx + c.nx, c.bx - c.nx, c.ax - c.nx]
const qy = [c.ay + c.ny, c.by + c.ny, c.by - c.ny, c.ay - c.ny]
let lo = Infinity
let hi = -Infinity
for (let k = 0; k < 4; k++) {
const j = (k + 1) % 4
const y0 = qy[k]
const y1 = qy[j]
// Half-open, so a corner shared by two edges is counted once.
if (y0 === y1) continue
const top = Math.min(y0, y1)
const bot = Math.max(y0, y1)
if (y < top || y >= bot) continue
const x = qx[k] + ((y - y0) / (y1 - y0)) * (qx[j] - qx[k])
if (x < lo) lo = x
if (x > hi) hi = x
}
if (hi > lo) parts.push({ x0: lo, x1: hi })
const da = disc(c.ax, c.ay, y)
if (da) parts.push(da)
const db = disc(c.bx, c.by, y)
if (db) parts.push(db)
}
if (!parts.length) return []
// Merge: adjacent capsules always overlap at their shared joint, so without
// this a row would be handed a dozen overlapping intervals and the packer
// would place a dot in each.
parts.sort((a, b) => a.x0 - b.x0)
/** @type {Span[]} */
const spans = [parts[0]]
for (let i = 1; i < parts.length; i++) {
const last = spans[spans.length - 1]
if (parts[i].x0 <= last.x1) {
if (parts[i].x1 > last.x1) last.x1 = parts[i].x1
} else {
spans.push(parts[i])
}
}
return spans
}
return { minY, maxY, minX, maxX, spansAt }
}
/**
* Fit a shape's bounds into the plot rect at a UNIFORM scale. A shape stretched
* to the plot's aspect stops being that shape, so the spare axis becomes margin.
*
* @param {{x0:number,y0:number,x1:number,y1:number}} bounds
* @param {{x:number,y:number,width:number,height:number}} rect
* @param {number} padding share of the rect to fill
* @returns {{ scale: number, offX: number, offY: number }}
*/
export function fitBox(bounds, rect, padding) {
const bw = Math.max(1e-6, bounds.x1 - bounds.x0)
const bh = Math.max(1e-6, bounds.y1 - bounds.y0)
const scale = Math.min(
(rect.width * padding) / bw,
(rect.height * padding) / bh,
)
return {
scale,
offX: rect.x + rect.width / 2 - (bounds.x0 + bw / 2) * scale,
offY: rect.y + rect.height / 2 - (bounds.y0 + bh / 2) * scale,
}
}