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.

1,385 lines (1,384 loc) 53.8 kB
import { arc as createArc, areaRadial as createAreaRadial, curveLinearClosed, lineRadial as createLineRadial, pointRadial } from "d3-shape"; import { channelValues, inferredKeyValues, isChartKey, isChartValue, isFiniteNumber, isNonnegativeFiniteNumber, visualValue } from "./mark.js"; import { resolveCompositeChildMotion } from "./composite-motion-internal.js"; import { createMarkWithScaleValues } from "./mark-with-scale-values.js"; import { createPolarMark } from "./polar-mark-internal.js"; import { focusGroupAngle, withPolarFocusGeometry } from "./polar-focus-internal.js"; import { resolvePolarSector, tracePolarArcBoundary } from "./polar-sector-internal.js"; import { resolveNumericScale, resolveScaleInput } from "./scale-input.js"; import { valueKey } from "./scales.js"; import { pie } from "./polar-pie.js"; const tau = Math.PI * 2; function polar(options) { return createMarkWithScaleValues( ({ markIndex }) => { const id = options.id ?? `polar-${markIndex}`; const marks = options.marks.map( (mark, polarMarkIndex) => mark.initialize({ markIndex: polarMarkIndex, parentId: id }) ); const childMotions = new Map( marks.flatMap((mark, markIndex2) => { const childMotion = mark.motion ?? options.marks[markIndex2]?.motion; return childMotion === void 0 ? [] : [[mark.id, childMotion]]; }) ); const motion = options.motion !== void 0 || childMotions.size > 0 ? (context) => resolveCompositeChildMotion(options.motion, childMotions, context) : void 0; return { id, ...motion === void 0 ? {} : { motion }, channels: { color: { scale: "color", values: marks.flatMap((mark) => mark.colorValues) } }, render: ({ chart, color, theme }) => { const layout = resolvePolarLayout(options, chart, marks); if (marks.some((mark) => mark.requiresAngleScale) && !layout.angle) { throw new TypeError( `Polar mark in "${id}" requires a configured angle scale` ); } if (marks.some((mark) => mark.requiresRadiusScale) && !layout.radiusScale) { throw new TypeError( `Polar mark in "${id}" requires a configured radius scale` ); } const nodes = []; const guideForeground = []; const points = []; for (const [guideIndex, guide] of (options.guides ?? []).entries()) { const rendered = guide.render({ layout, theme, guideIndex, parentId: id }); for (const node of rendered.background) nodes.push(node); for (const node of rendered.foreground ?? []) { guideForeground.push(node); } } for (const mark of marks) { const rendered = mark.render({ layout, color, theme }); for (const node of rendered.nodes) nodes.push(node); for (const point of rendered.points ?? []) points.push(point); } for (const node of guideForeground) nodes.push(node); return { nodes: [ { kind: "group", key: id, className: classes("ts-chart__polar", options.className), translateX: layout.centerX, translateY: layout.centerY, ariaHidden: true, children: nodes } ], points }; } }; }, options.motion ); } function radialArc(source, options = {}) { const data = asArray(source); return createPolarMark(({ markIndex, parentId }) => { const id = options.id ?? `${parentId}:arc-${markIndex}`; const startAngles = channelValues( data, options.startAngle, (datum) => numberProperty(datum, "startAngle") ); const endAngles = channelValues( data, options.endAngle, (datum) => numberProperty(datum, "endAngle") ); const padAngles = channelValues( data, options.padAngle, (datum) => numberProperty(datum, "padAngle") ?? 0 ); const groups = channelValues(data, options.z, () => null); const colorValues = options.color === void 0 ? groups : channelValues(data, options.color, () => null); const keys = inferredKeyValues(data, options.key, { groups }); return { id, colorValues: colorValues.filter(isChartKey), angleValues: [], radiusValues: [], includeZeroRadius: false, requiresAngleScale: false, requiresRadiusScale: false, render: ({ layout, color: resolveColor }) => { const innerRadius = resolveLength(options.innerRadius, layout, 0); const outerRadius = resolveLength( options.outerRadius, layout, layout.radius ); const generator = options.generator?.(layout) ?? createArc().startAngle((_datum, index) => startAngles[index] ?? 0).endAngle((_datum, index) => endAngles[index] ?? 0).padAngle((_datum, index) => padAngles[index] ?? 0).innerRadius(innerRadius).outerRadius(outerRadius).cornerRadius(resolveLength(options.cornerRadius, layout, 0)); if (options.padRadius !== void 0 && !options.generator) { generator.padRadius(resolveLength(options.padRadius, layout, 0)); } const nodes = []; const points = []; data.forEach((datum, datumIndex) => { const startAngle = startAngles[datumIndex]; const endAngle = endAngles[datumIndex]; const padAngle = padAngles[datumIndex]; if (!options.generator && (!isFiniteNumber(startAngle) || !isFiniteNumber(endAngle) || !isFiniteNumber(padAngle))) { return; } const path = generator(datum, datumIndex, data); if (typeof path !== "string" || !path) return; const group = groups[datumIndex] ?? null; const fallback = resolveColor(colorValues[datumIndex] ?? null); const fill = visualValue( options.fill, datum, datumIndex, data, fallback ); const stroke = options.stroke === void 0 ? void 0 : visualValue(options.stroke, datum, datumIndex, data, fallback); const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}`; const generatedStart = generator.startAngle()(datum, datumIndex, data); const generatedEnd = generator.endAngle()(datum, datumIndex, data); const generatedInner = generator.innerRadius()( datum, datumIndex, data ); const generatedOuter = generator.outerRadius()( datum, datumIndex, data ); const centroid = generator.centroid(datum, datumIndex, data); const angleValue = (generatedStart + generatedEnd) / 2; const radiusValue = (generatedInner + generatedOuter) / 2; const point = withPolarFocusGeometry( { key, markId: id, group, groupLabel: group == null ? id : String(group), datum, datumIndex, xValue: angleValue, yValue: radiusValue, x: layout.centerX + centroid[0], y: layout.centerY + centroid[1], color: fill }, layout, angleValue, radiusValue, centroid[0], centroid[1] ); nodes.push({ kind: "area", key, points: tracePolarArcBoundary(generator, datum, datumIndex, data), path, interaction: { point, affinity: "geometry" }, style: { fill, fillOpacity: options.fillOpacity, stroke, strokeOpacity: options.strokeOpacity, strokeWidth: options.strokeWidth, strokeDasharray: options.strokeDasharray, opacity: options.opacity, lineJoin: "round" } }); points.push(point); }); return { nodes: [ { kind: "group", key: id, className: classes("ts-chart__arc", options.className), ariaHidden: true, children: nodes } ], points }; } }; }, options.motion); } function radialBarRadius(source, options = {}) { const data = asArray(source); return createPolarMark(({ markIndex, parentId }) => { const id = options.id ?? `${parentId}:radial-bar-radius-${markIndex}`; const angleValues = channelValues( data, options.angle, (_datum, { index }) => index ); const rawRadiusValues = numericPolarChannelValues( data, options.radius ?? options.radius2, (datum) => typeof datum === "number" ? datum : void 0 ); const radius1Values = numericPolarChannelValues( data, options.radius1, () => 0 ); const radius2Values = numericPolarChannelValues( data, options.radius2 ?? options.radius, (_datum, { index }) => rawRadiusValues[index] ); const groups = channelValues(data, options.z, () => null); const colorValues = options.color === void 0 ? groups : channelValues(data, options.color, () => null); const keys = inferredKeyValues(data, options.key, { groups, candidates: [angleValues], markId: id, warningIdentity: options }); return { id, colorValues: colorValues.filter(isChartKey), angleValues: angleValues.filter(isChartValue), radiusValues: [ ...radius1Values.filter(isFiniteNumber), ...radius2Values.filter(isFiniteNumber) ], includeZeroRadius: options.radius1 === void 0, requiresAngleScale: true, requiresRadiusScale: true, render: ({ layout, color: resolveColor }) => { const angle = requiredBandScale(layout.angle, "angle", id); const radius = requiredScale(layout.radiusScale); const nodes = []; const points = []; data.forEach((datum, datumIndex) => { const angleValue = angleValues[datumIndex]; const radiusValue = rawRadiusValues[datumIndex]; const radius1Value = radius1Values[datumIndex]; const radius2Value = radius2Values[datumIndex]; if (!isChartValue(angleValue) || !isFiniteNumber(radiusValue) || !isFiniteNumber(radius1Value) || !isFiniteNumber(radius2Value)) { return; } const angleBand = resolvePolarBand(angle, angleValue); const mappedRadius1 = options.radius1 === void 0 ? 0 : mapPolarScale(radius, radius1Value); const mappedRadius2 = mapPolarScale(radius, radius2Value); if (!angleBand || !isNonnegativeFiniteNumber(mappedRadius1) || !isNonnegativeFiniteNumber(mappedRadius2)) { return; } const cornerRadius = resolveBarCornerRadius( options.cornerRadius, layout, mappedRadius1, mappedRadius2 ); const sector = resolvePolarSector({ startAngle: angleBand.start, endAngle: angleBand.end, innerRadius: mappedRadius1, outerRadius: mappedRadius2, cornerRadius }); if (!sector) return; const group = groups[datumIndex] ?? null; const fallback = resolveColor(colorValues[datumIndex] ?? null); const fill = visualValue( options.fill, datum, datumIndex, data, fallback ); const stroke = options.stroke === void 0 ? void 0 : visualValue(options.stroke, datum, datumIndex, data, fallback); const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}`; const [pointX, pointY] = pointRadial(angleBand.center, mappedRadius2); const point = withPolarFocusGeometry( { key, markId: id, group, groupLabel: group == null ? id : String(group), datum, datumIndex, xValue: angleValue, yValue: radiusValue, y1Value: radius1Value, y2Value: radius2Value, yInterval: "difference", x: layout.centerX + pointX, y: layout.centerY + pointY, color: fill }, layout, angleBand.center, mappedRadius2, pointX, pointY ); nodes.push({ kind: "area", key, points: sector.points, path: sector.path, interaction: { point, affinity: "geometry" }, style: { fill, fillOpacity: options.fillOpacity, stroke, strokeOpacity: options.strokeOpacity, strokeWidth: options.strokeWidth, strokeDasharray: options.strokeDasharray, opacity: options.opacity, lineJoin: "round" } }); points.push(point); }); return { nodes: [ { kind: "group", key: id, className: classes( "ts-chart__arc ts-chart__bar ts-chart__radial-bar ts-chart__radial-bar-radius", options.className ), ariaHidden: true, children: nodes } ], points }; } }; }, options.motion); } function radialBarAngle(source, options = {}) { const data = asArray(source); return createPolarMark(({ markIndex, parentId }) => { const id = options.id ?? `${parentId}:radial-bar-angle-${markIndex}`; const rawAngleValues = numericPolarChannelValues( data, options.angle ?? options.angle2, (datum) => typeof datum === "number" ? datum : void 0 ); const angle1Values = numericPolarChannelValues( data, options.angle1, () => 0 ); const angle2Values = numericPolarChannelValues( data, options.angle2 ?? options.angle, (_datum, { index }) => rawAngleValues[index] ); const radiusValues = channelValues( data, options.radius, (_datum, { index }) => index ); const groups = channelValues(data, options.z, () => null); const colorValues = options.color === void 0 ? groups : channelValues(data, options.color, () => null); const keys = inferredKeyValues(data, options.key, { groups, candidates: [radiusValues], markId: id, warningIdentity: options }); return { id, colorValues: colorValues.filter(isChartKey), angleValues: [ ...angle1Values.filter(isFiniteNumber), ...angle2Values.filter(isFiniteNumber) ], radiusValues: radiusValues.filter(isChartValue), includeZeroRadius: false, requiresAngleScale: true, requiresRadiusScale: true, render: ({ layout, color: resolveColor }) => { const angle = requiredScale(layout.angle); const radius = requiredBandScale(layout.radiusScale, "radius", id); const nodes = []; const points = []; data.forEach((datum, datumIndex) => { const angleValue = rawAngleValues[datumIndex]; const angle1Value = angle1Values[datumIndex]; const angle2Value = angle2Values[datumIndex]; const radiusValue = radiusValues[datumIndex]; if (!isFiniteNumber(angleValue) || !isFiniteNumber(angle1Value) || !isFiniteNumber(angle2Value) || !isChartValue(radiusValue)) { return; } const mappedAngle1 = mapPolarScale(angle, angle1Value); const mappedAngle2 = mapPolarScale(angle, angle2Value); const radiusBand = resolvePolarBand(radius, radiusValue); if (!isFiniteNumber(mappedAngle1) || !isFiniteNumber(mappedAngle2) || !radiusBand) { return; } const cornerRadius = resolveBarCornerRadius( options.cornerRadius, layout, radiusBand.start, radiusBand.end ); const sector = resolvePolarSector({ startAngle: mappedAngle1, endAngle: mappedAngle2, innerRadius: radiusBand.start, outerRadius: radiusBand.end, cornerRadius }); if (!sector) return; const group = groups[datumIndex] ?? null; const fallback = resolveColor(colorValues[datumIndex] ?? null); const fill = visualValue( options.fill, datum, datumIndex, data, fallback ); const stroke = options.stroke === void 0 ? void 0 : visualValue(options.stroke, datum, datumIndex, data, fallback); const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}`; const [pointX, pointY] = pointRadial(mappedAngle2, radiusBand.center); const point = withPolarFocusGeometry( { key, markId: id, group, groupLabel: group == null ? id : String(group), datum, datumIndex, xValue: angleValue, yValue: radiusValue, x1Value: angle1Value, x2Value: angle2Value, xInterval: "difference", x: layout.centerX + pointX, y: layout.centerY + pointY, color: fill }, layout, mappedAngle2, radiusBand.center, pointX, pointY ); nodes.push({ kind: "area", key, points: sector.points, path: sector.path, interaction: { point, affinity: "geometry" }, style: { fill, fillOpacity: options.fillOpacity, stroke, strokeOpacity: options.strokeOpacity, strokeWidth: options.strokeWidth, strokeDasharray: options.strokeDasharray, opacity: options.opacity, lineJoin: "round" } }); points.push(point); }); return { nodes: [ { kind: "group", key: id, className: classes( "ts-chart__arc ts-chart__bar ts-chart__radial-bar ts-chart__radial-bar-angle", options.className ), ariaHidden: true, children: nodes } ], points }; } }; }, options.motion); } function radialLine(source, options = {}) { const data = asArray(source); return createPolarMark(({ markIndex, parentId }) => { const id = options.id ?? `${parentId}:radial-line-${markIndex}`; const angleValues = typeof options.angle === "number" ? data.map(() => options.angle) : channelValues(data, options.angle, (_datum, { index }) => index); const radiusValues = typeof options.radius === "number" ? data.map(() => options.radius) : channelValues( data, options.radius, (datum) => typeof datum === "number" ? datum : void 0 ); const zValues = channelValues(data, options.z, () => null); const colorValues = options.color === void 0 ? zValues : channelValues(data, options.color, () => null); const groups = options.z === void 0 && options.color !== void 0 ? colorValues : zValues; const keys = inferredKeyValues(data, options.key, { groups, candidates: [angleValues], markId: id, warningIdentity: options }); return { id, colorValues: colorValues.filter(isChartKey), angleValues: angleValues.filter(isChartValue), radiusValues: radiusValues.filter(isChartValue), includeZeroRadius: false, requiresAngleScale: true, requiresRadiusScale: true, render: ({ layout, color: resolveColor }) => { const angle = requiredScale(layout.angle); const radius = requiredScale(layout.radiusScale); const nodes = []; const points = []; for (const [groupKey, indices] of groupIndices(groups)) { const firstIndex = indices[0]; if (firstIndex === void 0) continue; const group = groups[firstIndex] ?? null; const stroke = visualValue( options.stroke, data[firstIndex], firstIndex, data, resolveColor(colorValues[firstIndex] ?? null) ); const rows = indices.map((datumIndex) => ({ datumIndex, angleValue: angleValues[datumIndex], radiusValue: radiusValues[datumIndex], angle: mapPolarScale(angle, angleValues[datumIndex]), radius: mapPolarScale(radius, radiusValues[datumIndex]) })); const generator = createLineRadial().defined( (row) => isFiniteNumber(row.angle) && isFiniteNumber(row.radius) ).angle((row) => row.angle).radius((row) => row.radius); if (options.curve) generator.curve(options.curve); const path = generator(rows); if (typeof path === "string" && path) { nodes.push({ kind: "polyline", key: `${id}:${groupKey}`, points: [], path, style: { fill: "none", stroke, strokeOpacity: options.strokeOpacity, strokeWidth: options.strokeWidth ?? 2.25, strokeDasharray: options.strokeDasharray, opacity: options.opacity, lineCap: "round", lineJoin: "round" } }); } for (const row of rows) { if (!isChartValue(row.angleValue) || !isChartValue(row.radiusValue) || !isFiniteNumber(row.angle) || !isFiniteNumber(row.radius)) { continue; } const [x, y] = pointRadial(row.angle, row.radius); const key = `${id}:${groupKey}:${valueKey(keys[row.datumIndex])}`; const point = withPolarFocusGeometry( { key, markId: id, group, groupLabel: group == null ? id : String(group), datum: data[row.datumIndex], datumIndex: row.datumIndex, xValue: row.angleValue, yValue: row.radiusValue, x: layout.centerX + x, y: layout.centerY + y, color: stroke }, layout, row.angle, row.radius, x, y ); points.push(point); if (options.points) { nodes.push({ kind: "dot", key: `${key}:dot`, x, y, radius: 2.5, pointOwner: point, style: { fill: stroke } }); } } } return { nodes: [ { kind: "group", key: id, className: classes( "ts-chart__radial-line ts-chart__line", options.className ), ariaHidden: true, children: nodes } ], points }; } }; }, options.motion); } function radialArea(source, options = {}) { const data = asArray(source); return createPolarMark(({ markIndex, parentId }) => { const id = options.id ?? `${parentId}:radial-area-${markIndex}`; const angleValues = typeof options.angle === "number" ? data.map(() => options.angle) : channelValues(data, options.angle, (_datum, { index }) => index); const radiusValues = typeof options.radius === "number" ? data.map(() => options.radius) : channelValues( data, options.radius, (datum) => typeof datum === "number" ? datum : void 0 ); const radius1Values = typeof options.radius1 === "number" ? data.map(() => options.radius1) : channelValues(data, options.radius1, () => 0); const zValues = channelValues(data, options.z, () => null); const colorValues = options.color === void 0 ? zValues : channelValues(data, options.color, () => null); const groups = options.z === void 0 && options.color !== void 0 ? colorValues : zValues; const keys = inferredKeyValues(data, options.key, { groups, candidates: [angleValues], markId: id, warningIdentity: options }); return { id, colorValues: colorValues.filter(isChartKey), angleValues: angleValues.filter(isChartValue), radiusValues: [ ...radiusValues.filter(isChartValue), ...radius1Values.filter(isChartValue) ], includeZeroRadius: options.radius1 === void 0, requiresAngleScale: true, requiresRadiusScale: true, render: ({ layout, color: resolveColor }) => { const angle = requiredScale(layout.angle); const radius = requiredScale(layout.radiusScale); const nodes = []; const points = []; for (const [groupKey, indices] of groupIndices(groups)) { const firstIndex = indices[0]; if (firstIndex === void 0) continue; const datum = data[firstIndex]; const group = groups[firstIndex] ?? null; const fallback = resolveColor(colorValues[firstIndex] ?? null); const fill = visualValue( options.fill, datum, firstIndex, data, fallback ); const stroke = options.stroke === void 0 ? void 0 : visualValue(options.stroke, datum, firstIndex, data, fallback); const rows = indices.map((datumIndex) => ({ datumIndex, angleValue: angleValues[datumIndex], radiusValue: radiusValues[datumIndex], angle: mapPolarScale(angle, angleValues[datumIndex]), radius: mapPolarScale(radius, radiusValues[datumIndex]), radius1: mapPolarScale(radius, radius1Values[datumIndex]) })); const generator = createAreaRadial().defined( (row) => isFiniteNumber(row.angle) && isFiniteNumber(row.radius) && isFiniteNumber(row.radius1) ).angle((row) => row.angle).innerRadius((row) => row.radius1).outerRadius((row) => row.radius); if (options.curve) generator.curve(options.curve); const path = generator(rows); if (typeof path === "string" && path) { nodes.push({ kind: "area", key: `${id}:${groupKey}`, points: [], path, style: { fill, fillOpacity: options.fillOpacity ?? 0.2, stroke, strokeOpacity: options.strokeOpacity, strokeWidth: options.strokeWidth, strokeDasharray: options.strokeDasharray, opacity: options.opacity, lineJoin: "round" } }); } for (const row of rows) { if (!isChartValue(row.angleValue) || !isChartValue(row.radiusValue) || !isFiniteNumber(row.angle) || !isFiniteNumber(row.radius)) { continue; } const [x, y] = pointRadial(row.angle, row.radius); const key = `${id}:${groupKey}:${valueKey(keys[row.datumIndex])}`; points.push( withPolarFocusGeometry( { key, markId: id, group, groupLabel: group == null ? id : String(group), datum: data[row.datumIndex], datumIndex: row.datumIndex, xValue: row.angleValue, yValue: row.radiusValue, x: layout.centerX + x, y: layout.centerY + y, color: fill }, layout, row.angle, row.radius, x, y ) ); } } return { nodes: [ { kind: "group", key: id, className: classes("ts-chart__radial-area", options.className), ariaHidden: true, children: nodes } ], points }; } }; }, options.motion); } function radialText(source, options = {}) { const data = asArray(source); return createPolarMark(({ markIndex, parentId }) => { const id = options.id ?? `${parentId}:radial-text-${markIndex}`; const angleValues = typeof options.angle === "number" ? data.map(() => options.angle) : channelValues(data, options.angle, (_datum, { index }) => index); const radiusValues = typeof options.radius === "number" ? data.map(() => options.radius) : channelValues( data, options.radius, (datum) => typeof datum === "number" ? datum : void 0 ); const textValues = channelValues( data, options.text, (datum) => datum == null ? "" : String(datum) ); const groups = channelValues(data, options.z, () => null); const colorValues = options.color === void 0 ? groups : channelValues(data, options.color, () => null); const keys = inferredKeyValues(data, options.key, { groups }); return { id, colorValues: colorValues.filter(isChartKey), angleValues: angleValues.filter(isChartValue), radiusValues: radiusValues.filter(isChartValue), includeZeroRadius: false, requiresAngleScale: true, requiresRadiusScale: true, render: ({ layout, color: resolveColor, theme }) => { const angle = requiredScale(layout.angle); const radius = requiredScale(layout.radiusScale); const nodes = []; const points = []; data.forEach((datum, datumIndex) => { const angleValue = angleValues[datumIndex]; const radiusValue = radiusValues[datumIndex]; const textValue = textValues[datumIndex]; const anglePosition = mapPolarScale(angle, angleValue); const radiusPosition = mapPolarScale(radius, radiusValue); if (!isChartValue(angleValue) || !isChartValue(radiusValue) || textValue == null || !isFiniteNumber(anglePosition) || !isFiniteNumber(radiusPosition)) { return; } const radiusOffset = visualValue( options.radiusOffset, datum, datumIndex, data, 0 ); const projectedRadius = radiusPosition + radiusOffset; if (!isFiniteNumber(radiusOffset) || !isFiniteNumber(projectedRadius)) { return; } const [baseX, baseY] = pointRadial(anglePosition, projectedRadius); const x = baseX + visualValue(options.dx, datum, datumIndex, data, 0); const y = baseY + visualValue(options.dy, datum, datumIndex, data, 0); const group = groups[datumIndex] ?? null; const colorValue = colorValues[datumIndex] ?? null; const fill = visualValue( options.fill, datum, datumIndex, data, colorValue == null ? theme.foreground : resolveColor(colorValue) ); const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}`; const authoredAnchor = visualValue( options.anchor, datum, datumIndex, data, "middle" ); nodes.push({ kind: "label", key, x, y, text: String(textValue), anchor: authoredAnchor === "outside" ? outsideRadialAnchor(anglePosition) : authoredAnchor, baseline: visualValue( options.baseline, datum, datumIndex, data, "middle" ), rotate: options.rotate === void 0 ? void 0 : visualValue(options.rotate, datum, datumIndex, data, 0), fontSize: options.fontSize, fontWeight: options.fontWeight, style: { fill } }); points.push( withPolarFocusGeometry( { key, markId: id, group, groupLabel: group == null ? id : String(group), datum, datumIndex, xValue: angleValue, yValue: radiusValue, x: layout.centerX + x, y: layout.centerY + y, color: fill }, layout, anglePosition, projectedRadius, x, y ) ); }); return { nodes: [ { kind: "group", key: id, className: classes( "ts-chart__radial-text ts-chart__text", options.className ), ariaHidden: true, children: nodes } ], points }; } }; }, options.motion); } function radialRule(source, options = {}) { const data = asArray(source); return createPolarMark(({ markIndex, parentId }) => { const id = options.id ?? `${parentId}:radial-rule-${markIndex}`; const angleValues = typeof options.angle === "number" ? data.map(() => options.angle) : channelValues(data, options.angle, (_datum, { index }) => index); const radius1Values = typeof options.radius1 === "number" ? data.map(() => options.radius1) : channelValues(data, options.radius1, () => 0); const radius2Values = typeof options.radius2 === "number" ? data.map(() => options.radius2) : channelValues( data, options.radius2, (datum) => typeof datum === "number" ? datum : numberProperty(datum, "radius2") ); const groups = channelValues(data, options.z, () => null); const colorValues = options.color === void 0 ? groups : channelValues(data, options.color, () => null); const keys = inferredKeyValues(data, options.key, { groups, candidates: [angleValues], markId: id, warningIdentity: options }); return { id, colorValues: colorValues.filter(isChartKey), angleValues: angleValues.filter(isChartValue), radiusValues: [ ...radius1Values.filter(isChartValue), ...radius2Values.filter(isChartValue) ], includeZeroRadius: options.radius1 === void 0, requiresAngleScale: true, requiresRadiusScale: true, render: ({ layout, color: resolveColor, theme }) => { const angle = requiredScale(layout.angle); const radius = requiredScale(layout.radiusScale); const nodes = []; data.forEach((datum, datumIndex) => { const anglePosition = mapPolarScale(angle, angleValues[datumIndex]); const radius1Position = mapPolarScale( radius, radius1Values[datumIndex] ); const radius2Position = mapPolarScale( radius, radius2Values[datumIndex] ); if (!isFiniteNumber(anglePosition) || !isFiniteNumber(radius1Position) || !isFiniteNumber(radius2Position)) { return; } const radius1Offset = visualValue( options.radius1Offset, datum, datumIndex, data, 0 ); const radius2Offset = visualValue( options.radius2Offset, datum, datumIndex, data, 0 ); const projectedRadius1 = radius1Position + radius1Offset; const projectedRadius2 = radius2Position + radius2Offset; if (!isFiniteNumber(radius1Offset) || !isFiniteNumber(radius2Offset) || !isFiniteNumber(projectedRadius1) || !isFiniteNumber(projectedRadius2)) { return; } const [x1, y1] = pointRadial(anglePosition, projectedRadius1); const [x2, y2] = pointRadial(anglePosition, projectedRadius2); const group = groups[datumIndex] ?? null; const colorValue = colorValues[datumIndex] ?? null; const stroke = visualValue( options.stroke, datum, datumIndex, data, colorValue == null ? theme.foreground : resolveColor(colorValue) ); nodes.push({ kind: "rule", key: `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}`, x1, y1, x2, y2, style: { stroke, strokeOpacity: options.strokeOpacity, strokeWidth: options.strokeWidth ?? 1.5, strokeDasharray: options.strokeDasharray, opacity: options.opacity, lineCap: "round" } }); }); return { nodes: [ { kind: "group", key: id, className: classes( "ts-chart__radial-rule ts-chart__rule", options.className ), ariaHidden: true, children: nodes } ] }; } }; }, options.motion); } function radialDot(source, options = {}) { const data = asArray(source); return createPolarMark(({ markIndex, parentId }) => { const id = options.id ?? `${parentId}:radial-dot-${markIndex}`; const angleValues = typeof options.angle === "number" ? data.map(() => options.angle) : channelValues(data, options.angle, (_datum, { index }) => index); const radiusValues = typeof options.radius === "number" ? data.map(() => options.radius) : channelValues( data, options.radius, (datum) => typeof datum === "number" ? datum : void 0 ); const groups = channelValues(data, options.z, () => null); const colorValues = options.color === void 0 ? groups : channelValues(data, options.color, () => null); const keys = inferredKeyValues(data, options.key, { groups }); const rawRadii = typeof options.r === "number" ? data.map(() => options.r) : channelValues(data, options.r, () => 3.5); const radiusMapper = resolveNumericScale(options.rScale, rawRadii); const radii = radiusMapper ? rawRadii.map( (value) => isNonnegativeFiniteNumber(value) ? radiusMapper(value) : Number.NaN ) : rawRadii; return { id, colorValues: colorValues.filter(isChartKey), angleValues: angleValues.filter(isChartValue), radiusValues: radiusValues.filter(isChartValue), includeZeroRadius: false, requiresAngleScale: true, requiresRadiusScale: true, render: ({ layout, color: resolveColor }) => { const angle = requiredScale(layout.angle); const radius = requiredScale(layout.radiusScale); const nodes = []; const points = []; data.forEach((datum, datumIndex) => { const angleValue = angleValues[datumIndex]; const radiusValue = radiusValues[datumIndex]; const anglePosition = mapPolarScale(angle, angleValue); const radiusPosition = mapPolarScale(radius, radiusValue); const dotRadius = radii[datumIndex]; if (!isChartValue(angleValue) || !isChartValue(radiusValue) || !isFiniteNumber(anglePosition) || !isFiniteNumber(radiusPosition) || !isNonnegativeFiniteNumber(dotRadius)) { return; } const [x, y] = pointRadial(anglePosition, radiusPosition); const group = groups[datumIndex] ?? null; const fill = visualValue( options.fill, datum, datumIndex, data, resolveColor(colorValues[datumIndex] ?? null) ); const key = `${id}:${valueKey(group)}:${valueKey(keys[datumIndex])}`; nodes.push({ kind: "dot", key, x, y, radius: dotRadius, style: { fill, fillOpacity: options.fillOpacity, stroke: options.stroke, strokeOpacity: options.strokeOpacity, strokeWidth: options.strokeWidth, opacity: options.opacity } }); points.push( withPolarFocusGeometry( { key, markId: id, group, groupLabel: group == null ? id : String(group), datum, datumIndex, xValue: angleValue, yValue: radiusValue, x: layout.centerX + x, y: layout.centerY + y, color: fill }, layout, anglePosition, radiusPosition, x, y ) ); }); return { nodes: [ { kind: "group", key: id, className: classes( "ts-chart__radial-dot ts-chart__dot", options.className ), ariaHidden: true, children: nodes } ], points }; } }; }, options.motion); } function radialGrid(options = {}) { return { render: ({ layout, theme, guideIndex, parentId }) => { const radial = requiredScale(layout.radiusScale); const values = options.values ?? radial.ticks(options.ticks ?? 5); const stroke = options.stroke ?? theme.grid; const rings = []; const labels = []; for (const [index, value] of values.entries()) { const radius = radial.map(value); if (!isFiniteNumber(radius)) continue; let path; if (options.shape === "polygon") { if (!layout.angle) { throw new TypeError( "Polygon radial grid requires a configured angle scale" ); } path = polygonRingPath(layout.angle, radius); } else { path = createArc().innerRadius(0).outerRadius(radius).startAngle(0).endAngle(tau)(null); } if (typeof path === "string" && path) { rings.push({ kind: "polyline", key: `ring:${valueKey(value)}`, points: [], path, style: { fill: options.fill ?? "none", fillOpacity: options.fillOpacity, stroke, strokeOpacity: options.strokeOpacity, strokeWidth: options.strokeWidth ?? 1, strokeDasharray: options.strokeDasharray } }); } if (options.labels) { const angle = options.labelAngle ?? layout.startAngle; const [x, y] = pointRadial(angle, radius + (options.labelOffset ?? 0)); const labelContext = { value, index, angle, radius, x, y, layout }; labels.push({ kind: "label", key: `radius-label:${valueKey(value)}`, x: x + guideLabelOption(options.labelDx, labelContext, 0), y: y + guideLabelOption(options.labelDy, labelContext, 0), text: options.format?.(value) ?? String(value), anchor: guideLabelOption( options.labelAnchor, labelContext, "start" ), baseline: guideLabelOption( options.labelBaseline, labelContext, "middle" ), rotate: guideLabelOption(options.labelRotate, labelContext, 0), fontSize: options.labelFontSize ?? 12, style: { fill: options.labelFill ?? theme.muted } }); } } const id = options.id ?? `${parentId}:radial-grid-${guideIndex}`; return { background: [ { kind: "group", key: id, className: classes("ts-chart__radial-grid", options.className), ariaHidden: true, children: rings } ], foreground: labels.length ? [ { kind: "group", key: `${id}:labels`, className: classes("ts-chart__text", options.labelClassName), ariaHidden: true, children: labels } ] : void 0 }; } }; } function angleGrid(options = {}) { return { render: ({ layout, theme, guideIndex, parentId }) => { const angle = requiredScale(layout.angle); const values = options.values ?? angle.domain; const spokes = []; const labels = []; for (const [index, value] of values.entries()) { const position = angle.map(value); if (!isFiniteNumber(position)) continue; const [x2, y2] = pointRadial(position, layout.radius); spokes.push({ kind: "rule", key: `spoke:${valueKey(value)}`, x1: 0, y1: 0, x2, y2, style: { stroke: options.stroke ?? theme.grid, strokeOpacity: options.strokeOpacity, strokeWidth: options.strokeWidth ?? 1, strokeDasharray: options.strokeDasharray } }); if (options.labels !== false) { const [x, y] = pointRadial( position, layout.radius + (options.labelOffset ?? 8) ); const labelContext = { value, index, angle: position, radius: layout.radius, x, y, layout }; labels.push({ kind: "label", key: `angle-label:${valueKey(value)}`, x: x + guideLabelOption(options.labelDx, labelContext, 0), y: y + guideLabelOption(options.labelDy, labelContext, 0), text: options.format?.(value) ?? String(value), anchor: guideLabelOption( options.labelAnchor, labelContext, outsideRadialAnchor(position) ), baseline: guideLabelOption( options.labelBaseline, labelContext, Math.abs(y) < 1 ? "middle" : y < 0 ? "auto" : "hanging" ), rotate: guideLabelOption(options.labelRotate, labelContext, 0), fontSize: options.labelFontSize ?? 12, style: { fill: options.labelFill ?? theme.muted } }); } } const id = options.id ?? `${parentId}:angle-grid-${guideIndex}`; return { background: [ { kind: "group", key: id, className: classes("ts-chart__angle-grid", options.className), ariaHidden: true, children: spokes } ], foreground: labels.length ? [ { kind: "group", key: `${id}:labels`, className: classes("ts-chart__text", options.labelClassName), ariaHidden: true, children: labels } ] : void 0 }; } }; } function resolvePolarLayout(options, chart, marks) { const startAngle = finite(options.startAngle, 0); const endAngle = finite(options.endAngle, tau); const inset = Math.max(0, finite(options.inset, 0)); const radiusRatio = Math.max(0, finite(options.radiusRatio, 1)); const radius = Math.max(0, Math.min(chart.width, chart.height) / 2 - inset) * radiusRatio; const layout = { chart, centerX: chart.x + chart.width / 2, centerY: chart.y + chart.height / 2, radius, startAngle, endAngle }; if (options.angle) { const wrapPointScale = options.angle.wrap ?? isCompleteRevolution(startAngle, endAngle); layout.angle = resolvePolarScale( options.angle.scale, collectPolarValues(marks, "angleValues"), startAngle, endAngle, wrapPointScale, false, options.angle.nice ); } if (options.radius) { const [rangeStart, rangeEnd] = resolvePolarRadiusRange( options.radius.range, layout ); layout.radiusScale = resolvePolarScale( options.radius.scale, collectPolarValues(marks, "radiusValues"), rangeStart, rangeEnd, false, marks.some((mark) => mark.includeZeroRadius), options.radius.nice ); } return layout; } function resolvePolarScale(source, values, rangeStart, rangeEnd, wrapPointScale, includeZero, nice) { const scale = resolveScaleInput(source, { values, includeZero, nice, niceCount: 5 }); const domain = scale.domain().filter(isChartValue); const pointScale = wrapPointScale && typeof scale.bandwidth === "function" && scale.bandwidth() === 0; const resolvedEnd = pointScale ? domain.length > 1 ? rangeStart + (rangeEnd - rangeStart) * (domain.length - 1) / domain.length : rangeStart : rangeEnd; scale.range([rangeStart, resolvedEnd]); const bandwidth = scale.bandwidth?.() ?? 0; const map = (value) => { const position = scale(value); return typeof position === "number" && Number.isFinite(position) ? position + bandwidth / 2 : Number.NaN;