import { scaleLinear } from 'd3'; import type { ComponentType, JSXElement } from '../../jsx'; import { Defs, Ellipse, getElementBounds, Group, Path, Text } from '../../jsx'; import { ItemDatum, Padding } from '../../types'; import { getSimpleHash, parsePadding } from '../../utils'; import { ItemsGroup } from '../components'; import { FlexLayout } from '../layouts'; import { getColorPrimary, getPaletteColor, getThemeColors } from '../utils'; import { registerStructure } from './registry'; import type { BaseStructureProps } from './types'; export interface ChartLineProps extends BaseStructureProps { width?: number; height?: number; gap?: number; padding?: Padding; showValue?: boolean; valueFormatter?: (value: number, datum: ItemDatum) => string; } const ITEM_POSITION_H = 'center' as const; const ITEM_POSITION_V = 'normal' as const; export const ChartLine: ComponentType = (props) => { const { Title, Item, data, width, height = 260, gap = 10, padding = 24, showValue = true, options, valueFormatter = (value) => value.toString(), } = props; const { title, desc, items = [], xTitle, yTitle } = data; const titleContent = Title ? : null; if (items.length === 0) { return ( <FlexLayout id="infographic-container" flexDirection="column" justifyContent="center" alignItems="center" > {titleContent} </FlexLayout> ); } const [paddingTop, paddingRight, paddingBottom, paddingLeft] = parsePadding(padding); const itemProps = { indexes: [0], datum: items[0], data, positionH: ITEM_POSITION_H, positionV: ITEM_POSITION_V, }; const sampleBounds = getElementBounds(<Item {...itemProps} />); const labelWidth = sampleBounds.width; const labelHeight = sampleBounds.height; const xTickSpace = Math.max(28, labelHeight + 14); const yTickSpace = 44; const yTitleSpace = yTitle ? 26 : 0; const xTitleSpace = xTitle ? 22 : 0; const baseSpacing = labelWidth + gap; const naturalChartWidth = items.length > 0 ? (items.length - 1) * baseSpacing + labelWidth : 0; const targetChartWidth = width !== undefined ? width : Math.max(200, naturalChartWidth || 320); const spacing = items.length > 1 ? Math.max( baseSpacing, (targetChartWidth - labelWidth) / (items.length - 1), ) : 0; const derivedChartWidth = items.length > 0 ? Math.max(labelWidth, (items.length - 1) * spacing + labelWidth) : 0; const chartOriginX = paddingLeft + yTickSpace + yTitleSpace; const chartOriginY = paddingTop + yTitleSpace; const totalWidth = chartOriginX + derivedChartWidth + paddingRight; const totalHeight = chartOriginY + height + xTickSpace + xTitleSpace + paddingBottom; const values = items.map((item) => item.value ?? 0); const maxValue = Math.max(...values, 0); const minValue = Math.min(...values, 0); const domainPadding = Math.max(Math.abs(maxValue), Math.abs(minValue)) * 0.1; const domainMin = minValue < 0 ? minValue - domainPadding : 0; let domainMax = maxValue > 0 ? maxValue + domainPadding : 1; if (domainMax === domainMin) { domainMax = domainMin + 1; } const scaleY = scaleLinear() .domain([domainMin, domainMax]) .nice() .range([height, 0]); const zeroY = Math.max(0, Math.min(height, scaleY(0))); const axisBaseY = domainMax <= 0 ? 0 : domainMin >= 0 ? height : zeroY; const colorPrimary = getColorPrimary(options); const themeColors = getThemeColors(options.themeConfig); const axisColor = themeColors.colorText || '#666'; const gridElements: JSXElement[] = []; const axisElements: JSXElement[] = []; const lineElements: JSXElement[] = []; const pointElements: JSXElement[] = []; const valueElements: JSXElement[] = []; const titleElements: JSXElement[] = []; const tickElements: JSXElement[] = []; const ticksY = scaleY.ticks(6); const formatTickY = scaleY.tickFormat(6); ticksY.forEach((tick) => { const yPos = chartOriginY + scaleY(tick); gridElements.push( <Path d={`M ${chartOriginX} ${yPos} L ${ chartOriginX + derivedChartWidth } ${yPos}`} width={derivedChartWidth} height={1} stroke={axisColor} strokeWidth={1} data-element-type="shape" opacity={0.08} />, ); tickElements.push( <Text x={chartOriginX - 8} y={yPos} alignHorizontal="right" alignVertical="middle" fontSize={12} fill={axisColor} > {formatTickY(tick)} </Text>, ); }); const xLabels: JSXElement[] = []; const pointPositions: { x: number; y: number; datum: ItemDatum }[] = []; const colorStopsData: { x: number; color: string }[] = []; const createSmoothPath = (points: { x: number; y: number }[]) => { if (points.length === 0) return ''; if (points.length === 1) { const p = points[0]; return `M ${p.x} ${p.y}`; } const segments: string[] = []; segments.push(`M ${points[0].x} ${points[0].y}`); for (let i = 0; i < points.length - 1; i++) { const p0 = points[i - 1] || points[i]; const p1 = points[i]; const p2 = points[i + 1]; const p3 = points[i + 2] || p2; const cp1x = p1.x + (p2.x - p0.x) / 6; const cp1y = p1.y + (p2.y - p0.y) / 6; const cp2x = p2.x - (p3.x - p1.x) / 6; const cp2y = p2.y - (p3.y - p1.y) / 6; segments.push(`C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}`); } return segments.join(' '); }; items.forEach((item, index) => { const x = chartOriginX + index * spacing + labelWidth / 2; const y = chartOriginY + scaleY(item.value ?? 0); pointPositions.push({ x, y, datum: item }); xLabels.push( <Item {...itemProps} indexes={[index]} datum={item} x={x - labelWidth / 2} y={chartOriginY + height + 4} />, ); const stopColor = getPaletteColor(options, [index]) || themeColors.colorPrimary; colorStopsData.push({ x, color: stopColor }); }); const minStopX = colorStopsData.length > 0 ? Math.min(...colorStopsData.map((s) => s.x)) : chartOriginX; const maxStopX = colorStopsData.length > 0 ? Math.max(...colorStopsData.map((s) => s.x)) : chartOriginX + derivedChartWidth; const stopSpan = Math.max(1, maxStopX - minStopX); const gradientStops = colorStopsData.map((s) => ( <stop offset={`${((s.x - minStopX) / stopSpan) * 100}%`} stopColor={s.color} /> )); const areaStops = colorStopsData.map((s) => ( <stop offset={`${((s.x - minStopX) / stopSpan) * 100}%`} stopColor={s.color} stopOpacity="0.18" /> )); if (colorStopsData.length > 0) { const lastColor = colorStopsData[colorStopsData.length - 1].color; gradientStops.push(<stop offset="100%" stopColor={lastColor} />); areaStops.push( <stop offset="100%" stopColor={lastColor} stopOpacity="0.04" />, ); } const gradientIdBase = `chart-line-${getSimpleHash( [ derivedChartWidth, height, axisColor, colorPrimary, colorStopsData.map((s) => `${s.x.toFixed(2)}-${s.color}`).join(), ].join(':'), )}`; const gradientStrokeId = `${gradientIdBase}-stroke`; const gradientAreaId = `${gradientIdBase}-area`; const smoothLinePath = createSmoothPath(pointPositions); lineElements.push( <Path d={smoothLinePath} width={derivedChartWidth} height={height} stroke={`url(#${gradientStrokeId})`} strokeWidth={3} fill="none" data-element-type="shape" />, ); const areaCurveTail = smoothLinePath.includes('C') ? smoothLinePath.slice(smoothLinePath.indexOf('C')) : ''; const areaPath = [ `M ${pointPositions[0].x} ${chartOriginY + axisBaseY}`, `L ${pointPositions[0].x} ${pointPositions[0].y}`, areaCurveTail, `L ${pointPositions[pointPositions.length - 1].x} ${ chartOriginY + axisBaseY }`, 'Z', ].join(' '); lineElements.push( <Path d={areaPath} width={derivedChartWidth} height={height} fill={`url(#${gradientAreaId})`} stroke="none" data-element-type="shape" />, ); pointPositions.forEach((pos, index) => { const paletteColor = getPaletteColor(options, [index]) || themeColors.colorPrimary; pointElements.push( <Ellipse x={pos.x - 6} y={pos.y - 6} width={12} height={12} fill={paletteColor} data-element-type="shape" />, ); if (showValue) { valueElements.push( <Text x={pos.x} y={pos.y - 12} alignHorizontal="center" alignVertical="bottom" fontSize={12} fontWeight="bold" fill={paletteColor} > {valueFormatter(pos.datum.value ?? 0, pos.datum)} </Text>, ); } }); axisElements.push( <Path d={`M ${chartOriginX} ${chartOriginY + axisBaseY} L ${ chartOriginX + derivedChartWidth } ${chartOriginY + axisBaseY}`} width={derivedChartWidth} height={1} stroke={axisColor} strokeWidth={1} data-element-type="shape" />, ); axisElements.push( <Path d={`M ${chartOriginX} ${chartOriginY} L ${chartOriginX} ${ chartOriginY + height }`} width={1} height={height} stroke={axisColor} strokeWidth={1} data-element-type="shape" />, ); if (xTitle) { titleElements.push( <Text x={chartOriginX + derivedChartWidth / 2} y={chartOriginY + height + xTickSpace + xTitleSpace / 2} alignHorizontal="center" alignVertical="middle" fontSize={14} fontWeight="bold" fill={axisColor} > {xTitle} </Text>, ); } if (yTitle) { titleElements.push( <Text x={paddingLeft + yTitleSpace / 2} y={chartOriginY + height / 2} alignHorizontal="center" alignVertical="middle" fontSize={14} fontWeight="bold" fill={axisColor} > {yTitle} </Text>, ); } return ( <FlexLayout id="infographic-container" flexDirection="column" justifyContent="center" alignItems="center" > {titleContent} <Group width={totalWidth} height={totalHeight}> <Defs> <linearGradient id={gradientStrokeId} x1="0%" y1="0%" x2="100%" y2="0%" > {gradientStops} </linearGradient> <linearGradient id={gradientAreaId} x1="0%" y1="0%" x2="100%" y2="0%"> {areaStops} </linearGradient> </Defs> <Group>{gridElements}</Group> <Group>{[...axisElements, ...tickElements]}</Group> <Group>{lineElements}</Group> <Group>{pointElements}</Group> <Group>{valueElements}</Group> <Group>{titleElements}</Group> <ItemsGroup>{xLabels}</ItemsGroup> </Group> </FlexLayout> ); }; registerStructure('chart-line', { component: ChartLine, composites: ['title', 'item', 'xTitle', 'yTitle'], });