Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Line chart2 #15

Merged
merged 16 commits into from
Oct 8, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@
"test": "jest"
},
"dependencies": {
"d3-shape": "^3.2.0",
"lucide-react": "^0.445.0",
"react": "^19.0.0-rc-100dfd7dab-20240701",
"react-dom": "^19.0.0-rc-100dfd7dab-20240701",
2 changes: 2 additions & 0 deletions samples/App.tsx
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@ import PieChartWithPaddingAngle from './pages/PieChartWithPaddingAngle';
import PieChartWithCustomizedLabel from './pages/PieChartWithCustomizedLabel';
import CustomActiveShapePieChart from './pages/CustomActiveShapePieChart';
import LineChartExample from './pages/LineChartExample';
import DashedLineChart from './pages/DashedLineChart';

const App = () => {
return (
@@ -29,6 +30,7 @@ const App = () => {
<Route path="/pie-chart-with-customized-label" element={<PieChartWithCustomizedLabel />} />
<Route path="/custom-active-shape-pie" element={<CustomActiveShapePieChart />} />
<Route path="/line-chart" element={<LineChartExample />} />
<Route path="/dashed-line-chart" element={<DashedLineChart />} />
</Routes>
</div>
</Router>
13 changes: 13 additions & 0 deletions samples/pages/DashedLineChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import LineChartWrapper from '../utils/LineChartWrapper';

const DashedLineChart = () => {
const initialLines = [
{ id: 1, stroke: '#8884d8', dataKey: 'pv', strokeDasharray: '5 5', type: 'linear' },
{ id: 2, stroke: '#82ca9d', dataKey: 'uv', strokeDasharray: '3 3', type: 'monotoneX' },
];

return <LineChartWrapper initialLines={initialLines} />;
};

export default DashedLineChart;
63 changes: 9 additions & 54 deletions samples/pages/LineChartExample.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,13 @@
import React, { useState } from 'react';
import LineChart from '../../src/LineChart';
import Line from '../../src/Line';
import CartesianGrid from '../../src/CartesianGrid';
import XAxis from '../../src/XAxis';
import YAxis from '../../src/YAxis';
import Tooltip from '../../src/Tooltip';
import Legend from '../../src/Legend';
import LineChartControls from '../utils/LineChartControls';
import React from 'react';
import LineChartWrapper from '../utils/LineChartWrapper';

const data = [
{ name: 'Page A', uv: 4000, pv: 2400, amt: 2400 },
{ name: 'Page B', uv: 3000, pv: 1398, amt: 2210 },
{ name: 'Page C', uv: 2000, pv: 9800, amt: 2290 },
{ name: 'Page D', uv: 2780, pv: 3908, amt: 2000 },
{ name: 'Page E', uv: 1890, pv: 4800, amt: 2181 },
{ name: 'Page F', uv: 2390, pv: 3800, amt: 2500 },
{ name: 'Page G', uv: 3490, pv: 4300, amt: 2100 },
];
const LineChartExample = () => {
const initialLines = [
{ id: 1, stroke: '#8884d8', dataKey: 'pv', type: 'monotone' },
{ id: 2, stroke: '#82ca9d', dataKey: 'uv', type: 'linear' },
];

const LineChartExample: React.FC = () => {
const [lines, setLines] = useState([
{ id: 1, stroke: '#8884d8', dataKey: 'pv' },
{ id: 2, stroke: '#82ca9d', dataKey: 'uv' },
]);
const [width, setWidth] = useState(730);
const [height, setHeight] = useState(250);
const [margin, setMargin] = useState({ top: 5, right: 30, left: 20, bottom: 5 });

return (
<div className="p-6">
<div className="flex">
<LineChartControls
lines={lines}
setLines={setLines}
width={width}
setWidth={setWidth}
height={height}
setHeight={setHeight}
margin={margin}
setMargin={setMargin}
/>
<LineChart width={width} height={height} data={data} margin={margin}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
{lines.map((line) => (
<Line key={line.id} type="monotone" dataKey={line.dataKey} stroke={line.stroke} />
))}
</LineChart>
</div>
</div>
);
return <LineChartWrapper initialLines={initialLines} />;
};

export default LineChartExample;
export default LineChartExample;
25 changes: 21 additions & 4 deletions samples/utils/LineChartControls.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from 'react';

interface LineChartControlsProps {
lines: Array<{ id: number; stroke: string }>;
setLines: React.Dispatch<React.SetStateAction<Array<{ id: number; stroke: string }>>>;
lines: Array<{ id: number; stroke: string; type: string }>;
setLines: React.Dispatch<React.SetStateAction<Array<{ id: number; stroke: string; type: string }>>>;
width: number;
setWidth: React.Dispatch<React.SetStateAction<number>>;
height: number;
@@ -11,6 +11,10 @@ interface LineChartControlsProps {
setMargin: React.Dispatch<React.SetStateAction<{ top: number; right: number; bottom: number; left: number }>>;
}

const interpolationOptions = [
'linear', 'basis', 'basisClosed', 'basisOpen', 'bumpX', 'bumpY', 'natural', 'monotoneX', 'monotoneY', 'step', 'stepBefore', 'stepAfter',
];

export default function LineChartControls({
lines,
setLines,
@@ -78,7 +82,7 @@ export default function LineChartControls({
))}
</div>
</div>

{lines.map((line, index) => (
<div key={line.id} className="bg-gray-50 p-4 rounded-lg mb-4 shadow-sm">
<h3 className="text-lg font-medium text-indigo-700 mb-2">Line {index + 1} Settings</h3>
@@ -93,10 +97,23 @@ export default function LineChartControls({
/>
<span className="text-sm font-medium text-gray-600">{line.stroke}</span>
</div>

<label className="block text-sm font-medium text-gray-700 mt-2">Interpolation Type</label>
<select
value={line.type}
onChange={(e) => handleLineChange(index, 'type', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 transition duration-300 ease-in-out"
>
{interpolationOptions.map((option) => (
<option key={option} value={option}>
{option.charAt(0).toUpperCase() + option.slice(1)}
</option>
))}
</select>
</div>
</div>
))}
</form>
</div>
);
}
}
61 changes: 61 additions & 0 deletions samples/utils/LineChartWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React, { useState } from 'react';
import LineChart from '../../src/LineChart';
import Line from '../../src/Line';
import CartesianGrid from '../../src/CartesianGrid';
import XAxis from '../../src/XAxis';
import YAxis from '../../src/YAxis';
import Tooltip from '../../src/Tooltip';
import Legend from '../../src/Legend';
import LineChartControls from './LineChartControls';

const data = [
{ name: 'Page A', uv: 4000, pv: 2400, amt: 2400 },
{ name: 'Page B', uv: 3000, pv: 1398, amt: 2210 },
{ name: 'Page C', uv: 2000, pv: 9800, amt: 2290 },
{ name: 'Page D', uv: 2780, pv: 3908, amt: 2000 },
{ name: 'Page E', uv: 1890, pv: 4800, amt: 2181 },
{ name: 'Page F', uv: 2390, pv: 3800, amt: 2500 },
{ name: 'Page G', uv: 3490, pv: 4300, amt: 2100 },
];

const LineChartWrapper = ({ initialLines }) => {
const [lines, setLines] = useState(initialLines);
const [width, setWidth] = useState(730);
const [height, setHeight] = useState(250);
const [margin, setMargin] = useState({ top: 5, right: 30, left: 20, bottom: 5 });

return (
<div className="p-6">
<div className="flex">
<LineChartControls
lines={lines}
setLines={setLines}
width={width}
setWidth={setWidth}
height={height}
setHeight={setHeight}
margin={margin}
setMargin={setMargin}
/>
<LineChart width={width} height={height} data={data} margin={margin}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
{lines.map((line) => (
<Line
key={line.id}
type={line.type}
dataKey={line.dataKey}
stroke={line.stroke}
strokeDasharray={line.strokeDasharray}
/>
))}
</LineChart>
</div>
</div>
);
};

export default LineChartWrapper;
1 change: 1 addition & 0 deletions samples/utils/NavBar.tsx
Original file line number Diff line number Diff line change
@@ -34,6 +34,7 @@ export default function NavBar() {
category: 'Line Charts',
items: [
{ name: 'Line Chart', path: '/line-chart' },
{ name: 'Dashed Line Chart', path: '/dashed-line-chart' },
],
},
];
46 changes: 34 additions & 12 deletions src/Line/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
import React from 'react';
import * as d3Shape from 'd3-shape';

interface LineProps {
data: Array<{ [key: string]: any }>;
dataKey: string;
stroke: string;
type?: 'monotone' | 'linear';
strokeDasharray?: string;
type?:
| 'basis'
| 'basisClosed'
| 'basisOpen'
| 'bumpX'
| 'bumpY'
| 'bump'
| 'linear'
| 'linearClosed'
| 'natural'
| 'monotoneX'
| 'monotoneY'
| 'monotone'
| 'step'
| 'stepBefore'
| 'stepAfter';
chartWidth: number;
chartHeight: number;
onMouseOver?: (event: React.MouseEvent, entry: { name: string; [key: string]: any }) => void;
@@ -15,6 +32,7 @@ const Line: React.FC<LineProps> = ({
data = [],
dataKey,
stroke,
strokeDasharray = '0',
type = 'linear',
chartWidth,
chartHeight,
@@ -25,28 +43,32 @@ const Line: React.FC<LineProps> = ({

const maxValue = Math.max(...data.map((d) => d[dataKey]));

const points = data
.map((entry, index) => {
const x = (index + 0.5) * (chartWidth / data.length); // Centrado
const y = chartHeight - (entry[dataKey] / maxValue) * chartHeight;
return `${x},${y}`;
})
.join(' ');
const xScale = (index: number) => (index + 0.5) * (chartWidth / data.length);
const yScale = (value: number) => chartHeight - (value / maxValue) * chartHeight;

const lineGenerator = d3Shape
.line()
.x((d, index) => xScale(index))
.y((d) => yScale((d as any)[dataKey]))
.curve(d3Shape[`curve${type.charAt(0).toUpperCase() + type.slice(1)}`] || d3Shape.curveLinear);

const path = lineGenerator(data as [number, number][]);

return (
<>
<polyline
points={points}
<path
d={path || ''}
fill='none'
stroke={stroke}
strokeWidth={2}
strokeDasharray={strokeDasharray}
onMouseOver={(event) => onMouseOver(event, { name: dataKey })}
onMouseOut={onMouseOut}
style={{ transition: 'all 0.3s' }}
/>
{data.map((entry, index) => {
const x = (index + 0.5) * (chartWidth / data.length);
const y = chartHeight - (entry[dataKey] / maxValue) * chartHeight;
const x = xScale(index);
const y = yScale(entry[dataKey]);
return (
<circle
key={`point-${index}`}
123 changes: 109 additions & 14 deletions src/LineChart/index.tsx
Original file line number Diff line number Diff line change
@@ -14,6 +14,39 @@ interface LineChartProps {
children: ReactNode;
}

const findMinValue = (data: Array<{ [key: string]: any }>): number =>
Math.floor(
Math.min(...data.map((d) => Math.min(...Object.values(d).map((v) => (typeof v === 'number' ? v : Infinity))))),
);

const roundMaxValue = (data: Array<{ [key: string]: any }>): { maxValue: number; minValue: number } => {
const minValue = findMinValue(data);
const maxValue = Math.max(
...data.map((d) => Math.max(...Object.values(d).map((v) => (typeof v === 'number' ? v : -Infinity)))),
);

const magnitude = Math.pow(10, Math.floor(Math.log10(maxValue)));
const factor = maxValue / magnitude;

let finalMaxValue;
if (factor <= 1.5) {
finalMaxValue = 1.5 * magnitude;
} else if (factor <= 3) {
finalMaxValue = 3 * magnitude;
} else if (factor <= 7) {
finalMaxValue = 7 * magnitude;
} else {
finalMaxValue = 10 * magnitude;
}

const finalMinValue = minValue < 0 ? -finalMaxValue : 0;

return {
maxValue: Math.ceil(finalMaxValue),
minValue: finalMinValue,
};
};

const LineChart: React.FC<LineChartProps> = ({
width,
height,
@@ -23,10 +56,15 @@ const LineChart: React.FC<LineChartProps> = ({
}) => {
const chartWidth = width - ((margin.left ?? 0) + (margin.right ?? 0));
const chartHeight = height - ((margin.top ?? 0) + (margin.bottom ?? 0));
const [tooltipData, setTooltipData] = useState(null);
const [position, setPosition] = useState({ x: 0, y: 0 });

const { maxValue, minValue } = roundMaxValue(data);
const [leftMargin, setLeftMargin] = useState(margin.left);
const svgRef = useRef<SVGSVGElement>(null);
const [tooltipData, setTooltipData] = useState<{
name: string;
values: { key: string; value: number; color: string }[];
} | null>(null);
const [position, setPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });

useEffect(() => {
if (svgRef.current) {
@@ -39,27 +77,84 @@ const LineChart: React.FC<LineChartProps> = ({
}
}, [data, margin.left]);

const xAxis = Children.toArray(children).find((child) => React.isValidElement(child) && child.type === XAxis);
const yAxis = Children.toArray(children).find((child) => React.isValidElement(child) && child.type === YAxis);
const grid = Children.toArray(children).find(
(child) => React.isValidElement(child) && child.type === CartesianGrid,
);
const tooltip = Children.toArray(children).find((child) => React.isValidElement(child) && child.type === Tooltip);
const legend = Children.toArray(children).find((child) => React.isValidElement(child) && child.type === Legend);

const lineComponents = Children.toArray(children).filter(
(child) => React.isValidElement(child) && child.type === Line,
);

const legendItems = lineComponents.map((child) => {
if (React.isValidElement(child)) {
const lineChild = child as React.ReactElement;
return { color: lineChild.props.stroke, label: lineChild.props.dataKey };
}
return { color: '', label: '' };
});

const handleMouseMove = (event: React.MouseEvent<SVGSVGElement>) => {
const svgRect = svgRef.current?.getBoundingClientRect();
if (!svgRect) return;

const mouseX = event.clientX - svgRect.left - leftMargin;
const xScale = chartWidth / (data.length - 1);
const index = Math.round(mouseX / xScale);

if (index >= 0 && index < data.length) {
const entry = data[index];
const values = lineComponents
.map((child) => {
if (React.isValidElement(child)) {
const lineChild = child as React.ReactElement;
const dataKey = lineChild.props.dataKey;
return {
key: dataKey,
value: entry[dataKey],
color: lineChild.props.stroke,
};
}
return null;
})
.filter((v): v is { key: string; value: number; color: string } => v !== null);

setTooltipData({ name: entry.name, values });
setPosition({ x: event.clientX - svgRect.left, y: event.clientY - svgRect.top });
}
};

const handleMouseLeave = () => {
setTooltipData(null);
};

return (
<div className='relative inline-block'>
<svg ref={svgRef} width={width} height={height + height * 0.1} className='bg-white'>
<g transform={`translate(${leftMargin}, ${margin.top + height * 0.05})`}>
<CartesianGrid width={chartWidth} height={chartHeight} layout='horizontal' />
<XAxis data={data} width={chartWidth} height={chartHeight} dataKey='name' layout='horizontal' />
<YAxis
height={chartHeight}
maxValue={Math.max(...data.map((d) => d.pv))}
minValue={0}
layout='horizontal'
/>
<svg
ref={svgRef}
width={width}
height={height + height * 0.1}
className='bg-white'
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<g transform={`translate(${leftMargin}, ${(margin.top ?? 0) + height * 0.05})`}>
{grid && cloneElement(grid as React.ReactElement, { width: chartWidth, height: chartHeight })}
{xAxis &&
cloneElement(xAxis as React.ReactElement, { data, width: chartWidth, height: chartHeight })}
{yAxis && cloneElement(yAxis as React.ReactElement, { height: chartHeight, minValue, maxValue })}
{Children.map(children, (child) =>
React.isValidElement(child) && child.type === Line
? cloneElement(child, { data, chartWidth, chartHeight })
: child,
)}
</g>
</svg>
<Legend />
{tooltipData && <Tooltip tooltipData={tooltipData} position={position} />}
{legend && cloneElement(legend as React.ReactElement, { items: legendItems })}
{tooltip && <Tooltip tooltipData={tooltipData} position={position} />}
</div>
);
};