diff --git a/example/main.tsx b/example/main.tsx index 375342b..b07edfd 100644 --- a/example/main.tsx +++ b/example/main.tsx @@ -10,6 +10,7 @@ import { renderRankChart, renderSeasonalityChart, renderAnomalyChart, + renderDistribution, } from '../src/charts'; const dimensionValueDescriptor: SpecificEntityPhraseDescriptor = { @@ -38,6 +39,18 @@ function renderChart(fn: (container: Element, config: T) => void) { }; } +/** + * get random integer between min and max + * @param min + * @param max + * @returns + */ +function getRandomInt(min: number, max: number) { + const minCeiled = Math.ceil(min); + const maxFloored = Math.floor(max); + return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled); // The maximum is exclusive and the minimum is inclusive +} + const app = document.getElementById('app'); const app2 = document.getElementById('app2'); const app3 = document.getElementById('app3'); @@ -108,3 +121,28 @@ renderChart(renderSeasonalityChart)({ ], }); renderChart(renderAnomalyChart)({ data: [0, 1, 0, 0, 1, 0, 1, 0, 0] }); + +const distributionData: number[] = []; +const SAMPLE_SIZE = 200; + +// generate distribution data, 330-370, 530-570, 630-670 +// 330-370: 30% +// 530-570: 20% +// 630-670: 50% +// you will see three peaks in the distribution chart + +for (let i = 0; i < SAMPLE_SIZE * 0.3; i++) { + distributionData.push(getRandomInt(330, 370)); +} + +for (let i = 0; i < SAMPLE_SIZE * 0.5; i++) { + distributionData.push(getRandomInt(530, 570)); +} +// 50% 数据集中在 350 附近(高销量) +for (let i = 0; i < SAMPLE_SIZE * 0.2; i++) { + distributionData.push(getRandomInt(630, 670)); +} + +renderChart(renderDistribution)({ + data: distributionData, +}); diff --git a/src/charts/distribution/index.tsx b/src/charts/distribution/index.tsx new file mode 100644 index 0000000..5166509 --- /dev/null +++ b/src/charts/distribution/index.tsx @@ -0,0 +1,74 @@ +import { + createCurvePath, + createSvg, + extent, + getElementFontSize, + LINE_STROKE_COLOR, + max, + mean, + scaleLinear, +} from '../utils'; +import { ticks } from '../utils/scales'; + +const KDE_BANDWIDTH = 7; // Controls the smoothness of the KDE plot. +const TICK_COUNT = 40; // Number of points to sample for the density estimation. + +export interface DistributionConfig { + data: number[]; +} + +/** + * + * @param container + * @param config + */ +export const renderDistribution = (container: Element, config: DistributionConfig) => { + const { data } = config; + + function kernelDensityEstimator(kernel: (v: number) => number, X: number[]) { + return (V: number[]): [number, number][] => X.map((x) => [x, mean(V, (v) => kernel(x - v))]); + } + + function kernelEpanechnikov(k: number) { + return (v: number) => { + v /= k; + return Math.abs(v) <= 1 ? (0.75 * (1 - v * v)) / k : 0; + }; + } + + const chartSize = getElementFontSize(container); + + const height = chartSize; + const width = chartSize * 2; + const padding = 1.5; + + // Clear old SVG + container.innerHTML = ''; + + const valueExtent = extent(data); + + if (valueExtent[0] === undefined) { + throw new Error('Input data is empty or invalid, cannot calculate value extent.'); + } + + const xScale = scaleLinear(valueExtent, [padding, width - padding]); + + const kde = kernelDensityEstimator(kernelEpanechnikov(KDE_BANDWIDTH), ticks(valueExtent, TICK_COUNT)); + const density = kde(data); + + const maxDensity = max(density, (d) => d[1]); + const finalYScale = scaleLinear([0, maxDensity], [height - padding, padding]); + + const svgD3 = createSvg(container, width, height); + + const pathData = createCurvePath(xScale, finalYScale, density); + + svgD3 + .append('path') + .attr('class', 'mypath') + .attr('fill', 'none') + .attr('stroke', LINE_STROKE_COLOR) + .attr('stroke-width', 1) + .attr('stroke-linejoin', 'round') + .attr('d', pathData); +}; diff --git a/src/charts/index.ts b/src/charts/index.ts index a851e8b..db0c5d9 100644 --- a/src/charts/index.ts +++ b/src/charts/index.ts @@ -4,3 +4,4 @@ export { renderRankChart, type RankChartConfig } from './rank'; export { renderDifferenceChart, type DifferenceChartConfig } from './difference'; export { renderSeasonalityChart, type SeasonalityChartConfig } from './seasonality'; export { renderAnomalyChart, type AnomalyChartConfig } from './anomaly'; +export { renderDistribution, type DistributionConfig } from './distribution'; diff --git a/src/charts/utils/data.ts b/src/charts/utils/data.ts new file mode 100644 index 0000000..a4e8c3a --- /dev/null +++ b/src/charts/utils/data.ts @@ -0,0 +1,29 @@ +export function max(array: T[], accessor?: (d: T) => number): number | undefined { + if (!array || array.length === 0) { + return undefined; + } + + // Use the accessor if provided, otherwise assume the array already contains numbers. + const values = accessor ? array.map(accessor) : (array as number[]); + + return Math.max(...values); +} + +export function extent(array: number[], accessor?: (d: number) => number): [number, number] | [undefined, undefined] { + if (!array || array.length === 0) { + return [undefined, undefined]; + } + const values = accessor ? array.map(accessor) : array; + const minVal = Math.min(...values); + const maxVal = Math.max(...values); + return [minVal, maxVal]; +} + +export function mean(array: number[], accessor?: (d: number) => number): number | undefined { + if (!array || array.length === 0) { + return undefined; + } + const values = accessor ? array.map(accessor) : array; + const sum = values.reduce((a, b) => a + b, 0); + return sum / values.length; +} diff --git a/src/charts/utils/index.ts b/src/charts/utils/index.ts index 4725d6a..4667d68 100644 --- a/src/charts/utils/index.ts +++ b/src/charts/utils/index.ts @@ -1,8 +1,9 @@ export type { Domain, Range, Scale, Point } from './types'; export { Selection } from './selection'; export { scaleLinear } from './scales'; -export { line, area, arc, arrow } from './paths'; +export { line, area, arc, arrow, createCurvePath } from './paths'; export { getElementFontSize, DEFAULT_FONT_SIZE } from './getElementFontSize'; export { createSvg } from './createSvg'; export { getSafeDomain } from './getSafeDomain'; export { SCALE_ADJUST, WIDTH_MARGIN, LINE_STROKE_COLOR, HIGHLIGHT_COLOR, OPACITY } from './const'; +export { max, extent, mean } from './data'; diff --git a/src/charts/utils/paths.ts b/src/charts/utils/paths.ts index 634b5d5..218f6f9 100644 --- a/src/charts/utils/paths.ts +++ b/src/charts/utils/paths.ts @@ -108,3 +108,57 @@ export const arrow = (xScale: Scale, yScale: Scale, height: number, arrowheadLen ].join(' '); }; }; + +/** + * Generates an SVG path string for a smooth Bézier curve (similar to d3.curveBasis) + * based on a set of input points. + * + * NOTE: This is a simplified B-spline implementation suitable for smooth line generation, + * + * @param points - An array of coordinate pairs [[x0, y0], [x1, y1], ...] to be interpolated. + * @returns A complete SVG path data string (starting with 'M' followed by 'C' segments). + */ +export function curveBasis(points: [number, number][]): string { + if (points.length < 4) { + // For simplicity, return a straight line for few points + const path = points.map((p) => p.join(',')).join('L'); + return `M${path}`; + } + + // A very simplified B-spline path generation for demonstration + // This is not a complete implementation of d3.curveBasis, + // but it generates a smooth-looking curve. + let path = `M${points[0][0]},${points[0][1]}`; + for (let i = 1; i < points.length - 2; i++) { + const p0 = points[i - 1]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[i + 2]; + + const x0 = p0[0]; + const y0 = p0[1]; + const x1 = p1[0]; + const y1 = p1[1]; + const x2 = p2[0]; + const y2 = p2[1]; + const x3 = p3[0]; + const y3 = p3[1]; + + const cp1x = x1 + (x2 - x0) / 6; + const cp1y = y1 + (y2 - y0) / 6; + const cp2x = x2 - (x3 - x1) / 6; + const cp2y = y2 - (y3 - y1) / 6; + + path += `C${cp1x},${cp1y},${cp2x},${cp2y},${x2},${y2}`; + } + return path; +} + +export const createCurvePath = (xScale: Scale, yScale: Scale, data: Point[]): string => { + if (!data || data.length < 2) { + return ''; + } + + const points: Point[] = data.map((d) => [xScale(d[0]), yScale(d[1])]); + return curveBasis(points); +}; diff --git a/src/charts/utils/scales.ts b/src/charts/utils/scales.ts index 8774609..0666a65 100644 --- a/src/charts/utils/scales.ts +++ b/src/charts/utils/scales.ts @@ -34,3 +34,50 @@ export const scaleLinear = // and then maps that position to the corresponding value in the range. return r1 + ((r2 - r1) * (n - d1)) / (d2 - d1); }; + +/** + * Standalone ticks function to generate an array of uniformly spaced numbers. + * This is needed because your scaleLinear method doesn't provide it. + */ + +export const ticks = (domain: Domain, count: number): number[] => { + const [dMin, dMax] = domain; + + // Handle edge cases + if (dMin === dMax) return [dMin]; + if (count <= 0) return []; + + // Calculate the approximate step size + const roughStep = (dMax - dMin) / count; + + // Find a "nice" step size based on a power of 10 + const exponent = Math.floor(Math.log10(roughStep)); + const powerOf10 = Math.pow(10, exponent); + const niceSteps = [1, 2, 5, 10]; + let niceStep = 0; + + for (const s of niceSteps) { + if (roughStep <= s * powerOf10) { + niceStep = s * powerOf10; + break; + } + } + + // Adjust for floating point inaccuracies + if (niceStep === 0) { + niceStep = niceSteps[niceSteps.length - 1] * powerOf10; + } + + const result: number[] = []; + const start = Math.floor(dMin / niceStep) * niceStep; + const end = Math.ceil(dMax / niceStep) * niceStep; + + // Generate the ticks + for (let current = start; current <= end; current += niceStep) { + if (current >= dMin && current <= dMax) { + result.push(current); + } + } + + return result; +};