Skip to content

Commit 63e402a

Browse files
fnlearnerBQXBQX
andauthored
feat: 🎸 Add sparkline rendering function (#139)
* feat: 🎸 Add sparkline rendering function This commit adds the `renderDistribution2` function, which is used to render a kernel density estimation (KDE) sparkline chart within a given HTML element. * refactor: 💡 refactor the code * refactor: 💡 remove wordelement * refactor: 💡 adjust the directory structure * refactor: update renderDistribution * refactor: remove unused datum method from Selection class --------- Co-authored-by: bqxbqx <boqingxin14@gmail.com>
1 parent a57446b commit 63e402a

File tree

7 files changed

+245
-1
lines changed

7 files changed

+245
-1
lines changed

example/main.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
renderRankChart,
1111
renderSeasonalityChart,
1212
renderAnomalyChart,
13+
renderDistribution,
1314
} from '../src/charts';
1415

1516
const dimensionValueDescriptor: SpecificEntityPhraseDescriptor = {
@@ -38,6 +39,18 @@ function renderChart<T>(fn: (container: Element, config: T) => void) {
3839
};
3940
}
4041

42+
/**
43+
* get random integer between min and max
44+
* @param min
45+
* @param max
46+
* @returns
47+
*/
48+
function getRandomInt(min: number, max: number) {
49+
const minCeiled = Math.ceil(min);
50+
const maxFloored = Math.floor(max);
51+
return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled); // The maximum is exclusive and the minimum is inclusive
52+
}
53+
4154
const app = document.getElementById('app');
4255
const app2 = document.getElementById('app2');
4356
const app3 = document.getElementById('app3');
@@ -108,3 +121,28 @@ renderChart(renderSeasonalityChart)({
108121
],
109122
});
110123
renderChart(renderAnomalyChart)({ data: [0, 1, 0, 0, 1, 0, 1, 0, 0] });
124+
125+
const distributionData: number[] = [];
126+
const SAMPLE_SIZE = 200;
127+
128+
// generate distribution data, 330-370, 530-570, 630-670
129+
// 330-370: 30%
130+
// 530-570: 20%
131+
// 630-670: 50%
132+
// you will see three peaks in the distribution chart
133+
134+
for (let i = 0; i < SAMPLE_SIZE * 0.3; i++) {
135+
distributionData.push(getRandomInt(330, 370));
136+
}
137+
138+
for (let i = 0; i < SAMPLE_SIZE * 0.5; i++) {
139+
distributionData.push(getRandomInt(530, 570));
140+
}
141+
// 50% 数据集中在 350 附近(高销量)
142+
for (let i = 0; i < SAMPLE_SIZE * 0.2; i++) {
143+
distributionData.push(getRandomInt(630, 670));
144+
}
145+
146+
renderChart(renderDistribution)({
147+
data: distributionData,
148+
});

src/charts/distribution/index.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {
2+
createCurvePath,
3+
createSvg,
4+
extent,
5+
getElementFontSize,
6+
LINE_STROKE_COLOR,
7+
max,
8+
mean,
9+
scaleLinear,
10+
} from '../utils';
11+
import { ticks } from '../utils/scales';
12+
13+
const KDE_BANDWIDTH = 7; // Controls the smoothness of the KDE plot.
14+
const TICK_COUNT = 40; // Number of points to sample for the density estimation.
15+
16+
export interface DistributionConfig {
17+
data: number[];
18+
}
19+
20+
/**
21+
*
22+
* @param container
23+
* @param config
24+
*/
25+
export const renderDistribution = (container: Element, config: DistributionConfig) => {
26+
const { data } = config;
27+
28+
function kernelDensityEstimator(kernel: (v: number) => number, X: number[]) {
29+
return (V: number[]): [number, number][] => X.map((x) => [x, mean(V, (v) => kernel(x - v))]);
30+
}
31+
32+
function kernelEpanechnikov(k: number) {
33+
return (v: number) => {
34+
v /= k;
35+
return Math.abs(v) <= 1 ? (0.75 * (1 - v * v)) / k : 0;
36+
};
37+
}
38+
39+
const chartSize = getElementFontSize(container);
40+
41+
const height = chartSize;
42+
const width = chartSize * 2;
43+
const padding = 1.5;
44+
45+
// Clear old SVG
46+
container.innerHTML = '';
47+
48+
const valueExtent = extent(data);
49+
50+
if (valueExtent[0] === undefined) {
51+
throw new Error('Input data is empty or invalid, cannot calculate value extent.');
52+
}
53+
54+
const xScale = scaleLinear(valueExtent, [padding, width - padding]);
55+
56+
const kde = kernelDensityEstimator(kernelEpanechnikov(KDE_BANDWIDTH), ticks(valueExtent, TICK_COUNT));
57+
const density = kde(data);
58+
59+
const maxDensity = max(density, (d) => d[1]);
60+
const finalYScale = scaleLinear([0, maxDensity], [height - padding, padding]);
61+
62+
const svgD3 = createSvg(container, width, height);
63+
64+
const pathData = createCurvePath(xScale, finalYScale, density);
65+
66+
svgD3
67+
.append('path')
68+
.attr('class', 'mypath')
69+
.attr('fill', 'none')
70+
.attr('stroke', LINE_STROKE_COLOR)
71+
.attr('stroke-width', 1)
72+
.attr('stroke-linejoin', 'round')
73+
.attr('d', pathData);
74+
};

src/charts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export { renderRankChart, type RankChartConfig } from './rank';
44
export { renderDifferenceChart, type DifferenceChartConfig } from './difference';
55
export { renderSeasonalityChart, type SeasonalityChartConfig } from './seasonality';
66
export { renderAnomalyChart, type AnomalyChartConfig } from './anomaly';
7+
export { renderDistribution, type DistributionConfig } from './distribution';

src/charts/utils/data.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export function max<T>(array: T[], accessor?: (d: T) => number): number | undefined {
2+
if (!array || array.length === 0) {
3+
return undefined;
4+
}
5+
6+
// Use the accessor if provided, otherwise assume the array already contains numbers.
7+
const values = accessor ? array.map(accessor) : (array as number[]);
8+
9+
return Math.max(...values);
10+
}
11+
12+
export function extent(array: number[], accessor?: (d: number) => number): [number, number] | [undefined, undefined] {
13+
if (!array || array.length === 0) {
14+
return [undefined, undefined];
15+
}
16+
const values = accessor ? array.map(accessor) : array;
17+
const minVal = Math.min(...values);
18+
const maxVal = Math.max(...values);
19+
return [minVal, maxVal];
20+
}
21+
22+
export function mean(array: number[], accessor?: (d: number) => number): number | undefined {
23+
if (!array || array.length === 0) {
24+
return undefined;
25+
}
26+
const values = accessor ? array.map(accessor) : array;
27+
const sum = values.reduce((a, b) => a + b, 0);
28+
return sum / values.length;
29+
}

src/charts/utils/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
export type { Domain, Range, Scale, Point } from './types';
22
export { Selection } from './selection';
33
export { scaleLinear } from './scales';
4-
export { line, area, arc, arrow } from './paths';
4+
export { line, area, arc, arrow, createCurvePath } from './paths';
55
export { getElementFontSize, DEFAULT_FONT_SIZE } from './getElementFontSize';
66
export { createSvg } from './createSvg';
77
export { getSafeDomain } from './getSafeDomain';
88
export { SCALE_ADJUST, WIDTH_MARGIN, LINE_STROKE_COLOR, HIGHLIGHT_COLOR, OPACITY } from './const';
9+
export { max, extent, mean } from './data';

src/charts/utils/paths.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,57 @@ export const arrow = (xScale: Scale, yScale: Scale, height: number, arrowheadLen
108108
].join(' ');
109109
};
110110
};
111+
112+
/**
113+
* Generates an SVG path string for a smooth Bézier curve (similar to d3.curveBasis)
114+
* based on a set of input points.
115+
*
116+
* NOTE: This is a simplified B-spline implementation suitable for smooth line generation,
117+
*
118+
* @param points - An array of coordinate pairs [[x0, y0], [x1, y1], ...] to be interpolated.
119+
* @returns A complete SVG path data string (starting with 'M' followed by 'C' segments).
120+
*/
121+
export function curveBasis(points: [number, number][]): string {
122+
if (points.length < 4) {
123+
// For simplicity, return a straight line for few points
124+
const path = points.map((p) => p.join(',')).join('L');
125+
return `M${path}`;
126+
}
127+
128+
// A very simplified B-spline path generation for demonstration
129+
// This is not a complete implementation of d3.curveBasis,
130+
// but it generates a smooth-looking curve.
131+
let path = `M${points[0][0]},${points[0][1]}`;
132+
for (let i = 1; i < points.length - 2; i++) {
133+
const p0 = points[i - 1];
134+
const p1 = points[i];
135+
const p2 = points[i + 1];
136+
const p3 = points[i + 2];
137+
138+
const x0 = p0[0];
139+
const y0 = p0[1];
140+
const x1 = p1[0];
141+
const y1 = p1[1];
142+
const x2 = p2[0];
143+
const y2 = p2[1];
144+
const x3 = p3[0];
145+
const y3 = p3[1];
146+
147+
const cp1x = x1 + (x2 - x0) / 6;
148+
const cp1y = y1 + (y2 - y0) / 6;
149+
const cp2x = x2 - (x3 - x1) / 6;
150+
const cp2y = y2 - (y3 - y1) / 6;
151+
152+
path += `C${cp1x},${cp1y},${cp2x},${cp2y},${x2},${y2}`;
153+
}
154+
return path;
155+
}
156+
157+
export const createCurvePath = (xScale: Scale, yScale: Scale, data: Point[]): string => {
158+
if (!data || data.length < 2) {
159+
return '';
160+
}
161+
162+
const points: Point[] = data.map((d) => [xScale(d[0]), yScale(d[1])]);
163+
return curveBasis(points);
164+
};

src/charts/utils/scales.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,50 @@ export const scaleLinear =
3434
// and then maps that position to the corresponding value in the range.
3535
return r1 + ((r2 - r1) * (n - d1)) / (d2 - d1);
3636
};
37+
38+
/**
39+
* Standalone ticks function to generate an array of uniformly spaced numbers.
40+
* This is needed because your scaleLinear method doesn't provide it.
41+
*/
42+
43+
export const ticks = (domain: Domain, count: number): number[] => {
44+
const [dMin, dMax] = domain;
45+
46+
// Handle edge cases
47+
if (dMin === dMax) return [dMin];
48+
if (count <= 0) return [];
49+
50+
// Calculate the approximate step size
51+
const roughStep = (dMax - dMin) / count;
52+
53+
// Find a "nice" step size based on a power of 10
54+
const exponent = Math.floor(Math.log10(roughStep));
55+
const powerOf10 = Math.pow(10, exponent);
56+
const niceSteps = [1, 2, 5, 10];
57+
let niceStep = 0;
58+
59+
for (const s of niceSteps) {
60+
if (roughStep <= s * powerOf10) {
61+
niceStep = s * powerOf10;
62+
break;
63+
}
64+
}
65+
66+
// Adjust for floating point inaccuracies
67+
if (niceStep === 0) {
68+
niceStep = niceSteps[niceSteps.length - 1] * powerOf10;
69+
}
70+
71+
const result: number[] = [];
72+
const start = Math.floor(dMin / niceStep) * niceStep;
73+
const end = Math.ceil(dMax / niceStep) * niceStep;
74+
75+
// Generate the ticks
76+
for (let current = start; current <= end; current += niceStep) {
77+
if (current >= dMin && current <= dMax) {
78+
result.push(current);
79+
}
80+
}
81+
82+
return result;
83+
};

0 commit comments

Comments
 (0)