UNPKG

@tanstack/charts

Version:

A chart grammar for TypeScript and JavaScript. Marks consume your data directly, channels describe visual encodings, and the engine compiles them into a renderer-neutral keyed scene. TanStack's compact scales cover common numeric and categorical mappings.

381 lines (380 loc) 12 kB
const viewLayoutBrand = /* @__PURE__ */ Symbol("tanstack-view-layout-brand"); const viewLayoutRecord = /* @__PURE__ */ Symbol("tanstack-view-layout-record"); function fill(view) { const id = validId(view, "fill view"); return createLayout([id], [], (bounds, state) => { placeFrame(id, bounds, state); }); } function inset(view, options) { const resolvedView = validId(view, "inset view"); const relativeTo = validId(options.relativeTo, "inset relativeTo"); if (resolvedView === relativeTo) { throw new TypeError(`View inset "${resolvedView}" cannot reference itself`); } const placement = { relativeTo, anchor: validAnchor(options.anchor), width: positiveFinite(options.width, "View inset width"), height: positiveFinite(options.height, "View inset height"), offset: nonNegativeFinite(options.offset ?? 0, "View inset offset") }; return createLayout([resolvedView], [relativeTo], (_bounds, state) => { const target = state.frames.get(relativeTo); if (!target) { throw new TypeError( `View inset "${resolvedView}" cannot resolve "${relativeTo}"; inset references must target an earlier resolved view and may not form a cycle` ); } placeFrame(resolvedView, resolveInset(placement, target), state); }); } function layer(...layouts) { if (!layouts.length) throw new TypeError("layer requires at least one layout"); const children = layouts.map(layoutRecordOf); return createLayout( children.flatMap((child) => child.placed), unique(children.flatMap((child) => child.referenced)), (bounds, state) => { children.forEach((child) => child.resolve(bounds, state)); } ); } function grid(options) { const rows = validateTracks(options.rows, "row"); const columns = validateTracks(options.columns, "column"); const gap = nonNegativeFinite(options.gap ?? 12, "View grid gap"); const rowGap = nonNegativeFinite(options.rowGap ?? gap, "View grid rowGap"); const columnGap = nonNegativeFinite( options.columnGap ?? gap, "View grid columnGap" ); const cells = Object.entries(options.cells); if (!cells.length) throw new TypeError("grid requires at least one cell"); const viewIds = /* @__PURE__ */ new Set(); const occupied = /* @__PURE__ */ new Map(); const prepared = cells.map(([authoredView, cell]) => { const view = validId(authoredView, "grid view"); if (viewIds.has(view)) { throw new TypeError(`View grid contains duplicate view id "${view}"`); } viewIds.add(view); const row = validId(cell.row, `View grid "${view}" row`); const column = validId(cell.column, `View grid "${view}" column`); if (!rows.indexes.has(row)) { throw new TypeError( `View grid "${view}" references unknown row track "${row}"` ); } if (!columns.indexes.has(column)) { throw new TypeError( `View grid "${view}" references unknown column track "${column}"` ); } const coordinate = `${row}:${column}`; const existing = occupied.get(coordinate); if (existing) { throw new TypeError( `Views "${existing}" and "${view}" occupy the same grid cell` ); } occupied.set(coordinate, view); return { view, row, column }; }); const placement = { rows: rows.tracks, columns: columns.tracks, cells: prepared, rowGap, columnGap }; return createLayout( prepared.map((cell) => cell.view), [], (bounds, state) => resolveGrid(placement, bounds, state) ); } function getViewLayoutMetadataInternal(layout) { const record = layoutRecordOf(layout); return { // Preserve duplicate placements so composition can reject them before // scene compilation. Repeated references are valid and need no ordering. placed: [...record.placed], referenced: [...record.referenced] }; } function resolveViewLayoutInternal(layout, bounds) { const outer = validBounds(bounds); const record = layoutRecordOf(layout); const state = { frames: /* @__PURE__ */ new Map(), ordered: [] }; record.resolve(outer, state); return state.ordered; } function resolveGrid(placement, bounds, state) { const rowGap = fittedGap( bounds.height, placement.rowGap, placement.rows.length ); const columnGap = fittedGap( bounds.width, placement.columnGap, placement.columns.length ); const rowSizes = resolveTracks(placement.rows, bounds.height, rowGap); const columnSizes = resolveTracks(placement.columns, bounds.width, columnGap); const rowOffsets = trackOffsets(rowSizes, rowGap, bounds.y); const columnOffsets = trackOffsets(columnSizes, columnGap, bounds.x); const rowIndexes = new Map( placement.rows.map((track, index) => [track.id, index]) ); const columnIndexes = new Map( placement.columns.map((track, index) => [track.id, index]) ); placement.cells.forEach((cell) => { const rowIndex = rowIndexes.get(cell.row); const columnIndex = columnIndexes.get(cell.column); placeFrame( cell.view, { x: columnOffsets[columnIndex], y: rowOffsets[rowIndex], width: columnSizes[columnIndex], height: rowSizes[rowIndex] }, state ); }); } function placeFrame(id, bounds, state) { if (state.frames.has(id)) { throw new TypeError(`View layout places "${id}" more than once`); } const frame = { id, ...validBounds(bounds), order: state.ordered.length }; state.frames.set(id, frame); state.ordered.push(frame); } function resolveInset(placement, target) { const ratio = Math.min( 1, target.width / (placement.width + placement.offset * 2), target.height / (placement.height + placement.offset * 2) ); const width = placement.width * ratio; const height = placement.height * ratio; const offset = placement.offset * ratio; const left = target.x + offset; const centerX = target.x + (target.width - width) / 2; const right = target.x + target.width - offset - width; const top = target.y + offset; const centerY = target.y + (target.height - height) / 2; const bottom = target.y + target.height - offset - height; switch (placement.anchor) { case "top-left": return { x: left, y: top, width, height }; case "top": return { x: centerX, y: top, width, height }; case "top-right": return { x: right, y: top, width, height }; case "right": return { x: right, y: centerY, width, height }; case "bottom-right": return { x: right, y: bottom, width, height }; case "bottom": return { x: centerX, y: bottom, width, height }; case "bottom-left": return { x: left, y: bottom, width, height }; case "left": return { x: left, y: centerY, width, height }; case "center": return { x: centerX, y: centerY, width, height }; } } function createLayout(placed, referenced, resolve) { const record = Object.freeze({ placed: Object.freeze([...placed]), referenced: Object.freeze([...referenced]), resolve }); return Object.freeze({ [viewLayoutBrand]: void 0, [viewLayoutRecord]: record }); } function layoutRecordOf(layout) { const record = layout?.[viewLayoutRecord]; if (!record) { throw new TypeError( "View layouts must be created with fill, grid, layer, or inset" ); } return record; } function validateTracks(input, axis) { if (!input.length) { throw new TypeError(`grid requires at least one ${axis} track`); } const indexes = /* @__PURE__ */ new Map(); const tracks = input.map((authored, index) => { const id = validId(authored.id, `View grid ${axis} track ${index}`); if (indexes.has(id)) { throw new TypeError(`View grid contains duplicate ${axis} track "${id}"`); } indexes.set(id, index); if ("size" in authored && authored.size !== void 0) { if ("grow" in authored && authored.grow !== void 0) { throw new TypeError( `View grid ${axis} track "${id}" cannot set both size and grow` ); } return { id, size: positiveFinite( authored.size, `View grid ${axis} track "${id}" size` ) }; } if (!("grow" in authored) || authored.grow === void 0) { throw new TypeError( `View grid ${axis} track "${id}" requires size or grow` ); } const grow = positiveFinite( authored.grow, `View grid ${axis} track "${id}" grow` ); const min = authored.min === void 0 ? void 0 : positiveFinite(authored.min, `View grid ${axis} track "${id}" min`); const max = authored.max === void 0 ? void 0 : positiveFinite(authored.max, `View grid ${axis} track "${id}" max`); if (max !== void 0 && max < (min ?? 1)) { throw new TypeError( `View grid ${axis} track "${id}" max must be at least min` ); } return { id, grow, ...min === void 0 ? {} : { min }, ...max === void 0 ? {} : { max } }; }); return { tracks, indexes }; } function resolveTracks(tracks, total, gap) { const available = Math.max( Number.EPSILON, total - gap * Math.max(0, tracks.length - 1) ); const sizes = tracks.map( (track) => track.size !== void 0 ? track.size : track.min ?? 1 ); const preferred = sizes.reduce((sum, size) => sum + size, 0); if (preferred >= available) { const ratio = available / preferred; return sizes.map((size) => size * ratio); } let remaining = available - preferred; let active = tracks.flatMap( (track, index) => track.size !== void 0 ? [] : [index] ); while (remaining > Number.EPSILON && active.length) { const grow = active.reduce( (sum, index) => sum + tracks[index].grow, 0 ); let consumed = 0; const next = []; active.forEach((index) => { const track = tracks[index]; const addition = Math.min( remaining * track.grow / grow, (track.max ?? Infinity) - sizes[index] ); sizes[index] = sizes[index] + addition; consumed += addition; if (sizes[index] < (track.max ?? Infinity)) next.push(index); }); if (consumed <= Number.EPSILON) break; remaining -= consumed; active = next; } return sizes; } function fittedGap(total, requested, count) { if (count <= 1) return 0; return Math.min( requested, Math.max(0, (total - Number.EPSILON) / (count - 1)) ); } function trackOffsets(sizes, gap, origin) { let offset = origin; return sizes.map((size) => { const current = offset; offset += size + gap; return current; }); } function validBounds(bounds) { if (!Number.isFinite(bounds.x) || !Number.isFinite(bounds.y)) { throw new TypeError("View layout bounds require finite x and y coordinates"); } return { x: bounds.x, y: bounds.y, width: positiveFinite(bounds.width, "View layout bounds width"), height: positiveFinite(bounds.height, "View layout bounds height") }; } function validId(value, label) { const id = value.trim(); if (!id) throw new TypeError(`${label} requires a nonempty id`); return id; } function validAnchor(value) { const anchors = [ "top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left", "center" ]; if (!anchors.includes(value)) { throw new TypeError(`Unknown view inset anchor "${String(value)}"`); } return value; } function positiveFinite(value, label) { if (!Number.isFinite(value) || value <= 0) { throw new TypeError(`${label} must be positive and finite`); } return value; } function nonNegativeFinite(value, label) { if (!Number.isFinite(value) || value < 0) { throw new TypeError(`${label} must be nonnegative and finite`); } return value; } function unique(values) { return [...new Set(values)]; } export { fill, getViewLayoutMetadataInternal, grid, inset, layer, resolveViewLayoutInternal };