import type { SemanticTone } from '@purestack/ts-style' import { type ComputedRef, computed, defineComponent, type RefOrValue, svg, unref, } from 'regor' import { type CartesianChartDomain, pointIndexToCartesianChartX, type ResolvedCartesianChartGridLine, resolveCartesianChartDomain, resolveCartesianChartGridLines, valueToCartesianChartY, } from '../chart/cartesianChart' import { DEFAULT_CARTESIAN_CHART_HEIGHT, DEFAULT_CARTESIAN_CHART_WIDTH, DEFAULT_CHART_COLORS, DEFAULT_CHART_EMPTY_LABEL, } from '../chart/chartDefaults' import { formatChartNumber, formatChartPoint, formatChartValue, readChartNumber, resolveChartSize, resolveChartText, } from '../chart/chartUtils' import type { ComponentVariant, ComponentVariantMode, } from '../componentVariant' import { resolveComponentClasses } from '../componentVariant' export type LineChartCurve = 'linear' | 'smooth' export interface LineChartPoint { label?: RefOrValue value?: RefOrValue } export interface LineChartSeries { label?: RefOrValue color?: RefOrValue points?: RefOrValue>> } export interface ResolvedLineChartPoint { label: string value: number formattedValue: string x: number y: number valueLabelY: number valueLabelBaseline: string } export interface ResolvedLineChartSeries { label: string color: string path: string areaPath: string points: ResolvedLineChartPoint[] } export interface ResolvedLineChartGridLine extends ResolvedCartesianChartGridLine {} export interface ResolvedLineChartLabel { label: string x: number y: number } export interface LineChart { series?: RefOrValue>> title?: RefOrValue description?: RefOrValue ariaLabel?: RefOrValue emptyLabel?: RefOrValue valueSuffix?: RefOrValue width?: RefOrValue height?: RefOrValue minValue?: RefOrValue maxValue?: RefOrValue curve?: RefOrValue animated?: RefOrValue showArea?: RefOrValue showPoints?: RefOrValue showValues?: RefOrValue showLabels?: RefOrValue showAxis?: RefOrValue tone?: RefOrValue variant?: RefOrValue variantMode?: RefOrValue classes?: ComputedRef resolvedWidth?: ComputedRef resolvedHeight?: ComputedRef resolvedAriaLabel?: ComputedRef resolvedEmptyLabel?: ComputedRef chartSeries?: ComputedRef gridLines?: ComputedRef xLabels?: ComputedRef hasSeries?: ComputedRef zeroLineY?: ComputedRef } const VALUE_LABEL_OFFSET = 2.6 const CATEGORY_LABEL_Y = 60 const MAX_X_LABEL_COUNT = 7 const lineChartTemplate = svg` {{ title }} {{ description }} {{ line.formattedValue }} {{ item.label }} {{ item.label + ', ' + point.label + ': ' + point.formattedValue }} {{ point.formattedValue }} {{ label.label }} {{ resolvedEmptyLabel }} ` function defineLineChartComponent() { return defineComponent(lineChartTemplate, { props: [ 'series', 'title', 'description', 'ariaLabel', 'emptyLabel', 'valueSuffix', 'width', 'height', 'minValue', 'maxValue', 'curve', 'animated', 'showArea', 'showPoints', 'showValues', 'showLabels', 'showAxis', 'tone', 'variant', 'variantMode', ], context: (head) => resolveLineChart(head.props), }) } export function defineLineChartComponents() { return { lineChart: defineLineChartComponent(), } } function resolveLineChart(props: LineChart): LineChart { const domain = computed(() => resolveDomain(props)) const chartSeries = computed(() => resolveChartSeries(props, domain())) return { animated: true, showArea: false, showPoints: true, showValues: false, showLabels: true, showAxis: true, ...props, classes: computed(() => resolveComponentClasses(props, { defaultVariant: 'none', defaultVariantMode: 'stateless', }), ), resolvedWidth: computed(() => resolveChartSize(props.width, DEFAULT_CARTESIAN_CHART_WIDTH), ), resolvedHeight: computed(() => resolveChartSize(props.height, DEFAULT_CARTESIAN_CHART_HEIGHT), ), resolvedAriaLabel: computed(() => resolveAriaLabel(props)), resolvedEmptyLabel: computed( () => resolveChartText(props.emptyLabel) || DEFAULT_CHART_EMPTY_LABEL, ), chartSeries, gridLines: computed(() => resolveCartesianChartGridLines(domain(), props.valueSuffix), ), xLabels: computed(() => resolveXLabels(chartSeries())), hasSeries: computed(() => chartSeries().length > 0), zeroLineY: computed(() => valueToCartesianChartY(0, domain())), } } interface ResolvedLineChartSourceSeries { label: string color: string points: ResolvedLineChartSourcePoint[] } interface ResolvedLineChartSourcePoint { label: string value: number } function resolveChartSeries( props: LineChart, domain: CartesianChartDomain, ): ResolvedLineChartSeries[] { const valueSuffix = resolveChartText(props.valueSuffix) const curve = resolveCurve(props.curve) const zeroY = valueToCartesianChartY(0, domain) return resolveSeries(props.series).map((series, index) => { const points = series.points.map((point, pointIndex) => { const x = pointIndexToCartesianChartX(pointIndex, series.points.length) const y = valueToCartesianChartY(point.value, domain) const valueLabelY = point.value < 0 ? y + VALUE_LABEL_OFFSET : Math.max(2, y - VALUE_LABEL_OFFSET) return { label: point.label, value: point.value, formattedValue: formatChartValue(point.value, valueSuffix), x, y, valueLabelY, valueLabelBaseline: point.value < 0 ? 'hanging' : 'auto', } }) return { label: series.label, color: series.color || DEFAULT_CHART_COLORS[index % DEFAULT_CHART_COLORS.length], path: resolveLinePath(points, curve), areaPath: resolveAreaPath(points, zeroY, curve), points, } }) } function resolveSeries( seriesInput: LineChart['series'], ): ResolvedLineChartSourceSeries[] { return (unref(seriesInput) || []) .map((series, index) => { const resolvedSeries = unref(series) return { label: resolveChartText(resolvedSeries?.label) || `Series ${index + 1}`, color: resolveChartText(resolvedSeries?.color), points: resolvePoints(resolvedSeries?.points), } }) .filter((series) => series.points.length > 0) } function resolvePoints( pointsInput: LineChartSeries['points'], ): ResolvedLineChartSourcePoint[] { return (unref(pointsInput) || []) .map((pointInput, index) => { const point = unref(pointInput) const value = readChartNumber(point?.value, Number.NaN) return { label: resolveChartText(point?.label) || `Point ${index + 1}`, value, } }) .filter((point) => Number.isFinite(point.value)) } function resolveLinePath( points: ResolvedLineChartPoint[], curve: LineChartCurve, ) { if (!points.length) return '' if (points.length === 1) return `M ${formatChartPoint(points[0])}` if (curve === 'linear') { return points .map( (point, index) => `${index === 0 ? 'M' : 'L'} ${formatChartPoint(point)}`, ) .join(' ') } return [ `M ${formatChartPoint(points[0])}`, ...points.slice(1).map((point, index) => { const previous = points[index] const midpointX = (previous.x + point.x) / 2 return [ 'C', formatChartNumber(midpointX), formatChartNumber(previous.y), formatChartNumber(midpointX), formatChartNumber(point.y), formatChartPoint(point), ].join(' ') }), ].join(' ') } function resolveAreaPath( points: ResolvedLineChartPoint[], zeroY: number, curve: LineChartCurve, ) { if (!points.length) return '' const first = points[0] const last = points.at(-1) || first return [ `M ${formatChartNumber(first.x)} ${formatChartNumber(zeroY)}`, resolveLinePath(points, curve).replace(/^M /, 'L '), `L ${formatChartNumber(last.x)} ${formatChartNumber(zeroY)}`, 'Z', ].join(' ') } function resolveXLabels( series: ResolvedLineChartSeries[], ): ResolvedLineChartLabel[] { const points = series[0]?.points || [] if (!points.length) return [] const step = Math.max(1, Math.ceil(points.length / MAX_X_LABEL_COUNT)) return points .filter((_, index) => index % step === 0 || index === points.length - 1) .map((point) => ({ label: point.label, x: point.x, y: CATEGORY_LABEL_Y, })) } function resolveDomain(props: LineChart): CartesianChartDomain { const values = resolveSeries(props.series).flatMap((series) => series.points.map((point) => point.value), ) return resolveCartesianChartDomain({ values, minValue: props.minValue, maxValue: props.maxValue, }) } function resolveAriaLabel(props: LineChart) { return ( resolveChartText(props.ariaLabel) || resolveChartText(props.title) || 'Line chart' ) } function resolveCurve(value: RefOrValue | undefined) { return unref(value) === 'linear' ? 'linear' : 'smooth' }