diff --git a/CHANGELOG.md b/CHANGELOG.md index ea8d3fa..448ab4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Add `getColorScale` to receive color encoding in charts. (#11) - Extract the animation frame controls as a custom effect hook. (#10) - Add `tslint-react-hooks` rules to lint React Hooks. (#8) - Add `ThemeProvider` and color / xy axis themes config for customize theme. (#6) diff --git a/docs/charts/LineChart.mdx b/docs/charts/LineChart.mdx index b049bd1..8ee9c06 100644 --- a/docs/charts/LineChart.mdx +++ b/docs/charts/LineChart.mdx @@ -7,6 +7,7 @@ menu: Charts import { Playground, PropsTable } from 'docz' import { LineChart } from '@ichef/transcharts-chart' import lineData from '../sampleData/lineData'; +import multiLinesData from '../sampleData/multiLinesData' # Line Chart @@ -64,6 +65,56 @@ const lineData = [ +## Multiple lines + +### Nominal color field + + +
+ +
+
+ +### Quantitative color field + + +
+ +
+
+ + ## Props diff --git a/docs/sampleData/multiLinesData.js b/docs/sampleData/multiLinesData.js new file mode 100644 index 0000000..750f77a --- /dev/null +++ b/docs/sampleData/multiLinesData.js @@ -0,0 +1,14 @@ +export const mutliLinesData = [ + { x: 0, y: 9, type: "type1", date: '2019/01/21 00:00:00', weekday: 'Mon' }, + { x: 1, y: 5, type: "type2", date: '2019/01/22 00:00:00', weekday: 'Tue' }, + { x: 2, y: 5, type: "type2", date: '2019/01/23 00:00:00', weekday: 'Wed' }, + { x: 3, y: 3, type: "type1", date: '2019/01/24 00:00:00', weekday: 'Thu'}, + { x: 4, y: 1, type: "type2", date: '2019/01/25 00:00:00', weekday: 'Fri' }, + { x: 10, y: 9, type: "type1", date: '2019/01/21 00:00:00', weekday: 'Mon' }, + { x: 6, y: 5, type: "type2", date: '2019/01/22 00:00:00', weekday: 'Tue' }, + { x: 7, y: 5, type: "type2", date: '2019/01/23 00:00:00', weekday: 'Wed' }, + { x: 2, y: 3, type: "type1", date: '2019/01/24 00:00:00', weekday: 'Thu'}, + { x: 8, y: 1, type: "type2", date: '2019/01/25 00:00:00', weekday: 'Fri' }, +]; + +export default mutliLinesData; diff --git a/packages/chart/src/line/LineChart.tsx b/packages/chart/src/line/LineChart.tsx index 5358263..c0e66dd 100644 --- a/packages/chart/src/line/LineChart.tsx +++ b/packages/chart/src/line/LineChart.tsx @@ -11,8 +11,13 @@ import { TooltipLayer, // from common types Margin, + FieldSelector, + Encoding, AxisEncoding, + ColorEncoding, // from utils + getColorScale, + getDataGroupByEncodings, getXAxisScale, getYAxisScale, // from themes @@ -30,6 +35,7 @@ export interface LineChartProps { data: object[]; x: AxisEncoding; y: AxisEncoding; + color?: ColorEncoding; /** Should show the axis on the left or not */ showLeftAxis: boolean; /** Should show the axis on the bottom or not */ @@ -69,10 +75,45 @@ const HoveringIndicator: FunctionComponent<{ ); }; +const DataLine: FunctionComponent<{ + color: string, + xSelector: FieldSelector, + ySelector: FieldSelector, + rows: object[], +}> = ({ color, xSelector, ySelector, rows }) => { + const lineDots = rows.map((dataRow, index) => ( + + )); + return ( + <> + {/* Draw the line */} + + + {/* Draw dots on the line */} + {lineDots} + + ); +}; + export const LineChart: FunctionComponent = ({ data, x, y, + color, margin = { top: 20, right: 20, @@ -104,20 +145,36 @@ export const LineChart: FunctionComponent = ({ const xSelector = xAxis.selector; const ySelector = yAxis.selector; - const bandWidth = graphWidth / (data.length - 1); - /** Width of the collision detection rectangle */ - const color = theme.colors.category[0]; + const bandWidth = graphWidth / (data.length - 1); + const colorScale = (typeof color !== 'undefined') && getColorScale({ + data, + encoding: color, + colors: theme.colors, + }); + const defaultColor = theme.colors.category[0]; + const getColor = colorScale + ? colorScale.selector.getScaledVal + : () => defaultColor; + const sortedData = data.sort( + (rowA, rowB) => xSelector.getOriginalVal(rowA) - xSelector.getOriginalVal(rowB) + ); + const encodings = [color].filter((encoding): encoding is Encoding => !!encoding); + const dataGroup = getDataGroupByEncodings(sortedData, encodings); - const lineDots = data.map((dataRow, index) => ( - - )); + const graphGroup = dataGroup.map( + rows => { + const colorString: string = getColor(rows[0]); + return ( + + ); + } + ); return (
= ({ > - {/* Draw the axes */} = ({ yAxis={yAxis} /> - {/* Draw the line */} - - - {/* Draw dots on the line */} - {lineDots} + {graphGroup} {/* Areas which are used to detect mouse or touch interactions */} @@ -190,7 +234,6 @@ export const LineChart: FunctionComponent = ({ /> - {/* Draw the tooltip */} = ({ margin={margin} xSelector={xSelector} ySelector={ySelector} - color={color} + getColor={getColor} />
); diff --git a/packages/graph/src/common/types.ts b/packages/graph/src/common/types.ts index 1a2e621..252130e 100644 --- a/packages/graph/src/common/types.ts +++ b/packages/graph/src/common/types.ts @@ -34,7 +34,7 @@ export interface Scale { /** * Range of input channel */ - range: [any, any]; + range?: ReadonlyArray; /** Returns the formatted value according to the type of the axis */ getValue: (val: any) => any; @@ -55,10 +55,13 @@ export interface AxisScale extends Scale { * Range of the axis: [min, max] * it should match the inner width and height of the graph */ - range: [number, number]; + range?: [number, number]; } export type EncodingDataType = 'nominal' | 'ordinal' | 'quantitative' | 'temporal'; +export interface ColorScale extends Scale { + range?: [string, string] | ReadonlyArray +} export interface Encoding { field: string; @@ -70,6 +73,8 @@ export interface AxisEncoding extends Encoding { scale?: AxisScaleType; } +export type ColorEncoding = Encoding; + export interface Margin { top: number; right: number; @@ -93,6 +98,10 @@ export interface Theme { colors: { /** colors used for nominal data */ category: ReadonlyArray; + sequential: { + scheme: ReadonlyArray; + interpolator: (val: number) => string; + } }; /** x-axis theme config */ xAxis: AxisTheme; diff --git a/packages/graph/src/index.ts b/packages/graph/src/index.ts index 60b75db..1a05d2d 100644 --- a/packages/graph/src/index.ts +++ b/packages/graph/src/index.ts @@ -11,4 +11,6 @@ export * from './themes'; export * from './tooltip/Tooltip'; export * from './tooltip/TooltipItem'; export * from './utils/getAxisScale'; +export * from './utils/getColorScale'; +export * from './utils/getDataGroupByEncodings'; export * from './utils/getRecordFieldSelector'; diff --git a/packages/graph/src/layers/TooltipLayer.tsx b/packages/graph/src/layers/TooltipLayer.tsx index 5a754d5..fa484d7 100644 --- a/packages/graph/src/layers/TooltipLayer.tsx +++ b/packages/graph/src/layers/TooltipLayer.tsx @@ -13,7 +13,7 @@ export interface TooltipLayerProps { margin: Margin; xSelector: FieldSelector; ySelector: FieldSelector; - color: string; + getColor: FieldSelector['getScaledVal']; } /** Generates the tooltip box */ @@ -26,7 +26,7 @@ export const TooltipLayer: React.FC = ({ margin, xSelector, ySelector, - color, + getColor, }) => { const { index, position } = hoveredPoint; @@ -42,8 +42,10 @@ export const TooltipLayer: React.FC = ({ show={hovering} >

{xSelector.getFormattedStringVal(data[index])}

- {/* TODO: unify the way of dealing colors of the fields */} - + ); }; diff --git a/packages/graph/src/themes/index.tsx b/packages/graph/src/themes/index.tsx index abb8f05..cf431c8 100644 --- a/packages/graph/src/themes/index.tsx +++ b/packages/graph/src/themes/index.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { schemeCategory10 } from 'd3-scale-chromatic'; +import { schemeCategory10, interpolateBlues, schemeBlues } from 'd3-scale-chromatic'; import deepmerge from 'deepmerge'; import { Theme } from '../common/types'; @@ -14,6 +14,10 @@ export const themes = { default: { colors: { category: schemeCategory10, + sequential: { + scheme: schemeBlues[9], + interpolator: interpolateBlues, + }, }, xAxis: { strokeColor: '#7c8a94', diff --git a/packages/graph/src/utils/getColorScale.ts b/packages/graph/src/utils/getColorScale.ts new file mode 100644 index 0000000..3f60e06 --- /dev/null +++ b/packages/graph/src/utils/getColorScale.ts @@ -0,0 +1,104 @@ +import { map } from 'lodash-es'; +import { extent as d3Extent } from 'd3-array'; +import { scaleOrdinal, scaleSequential } from 'd3-scale'; + +import { Theme, ColorEncoding, ColorScale } from '../common/types'; + +import { getRecordFieldSelector } from './getRecordFieldSelector'; + +interface ColorScaleArgs { + colors: Theme['colors']; + encoding: ColorEncoding; + data: object[]; +} + +function getNumericDomain(values: number[]): [number, number] { + /** + * The `d3Extent` return [number, number] | [undefined, undefined]. + * Maybe there is better way to make compiler happy, it's a workaround now. + */ + const [extentMin = 0, extentMax = 0] = d3Extent(values); + return [extentMin, extentMax]; +} + +const getColorScaleSetting = ({ + colors, + encoding, + data, +}: ColorScaleArgs): Pick< + ColorScale, + Extract +> => { + const { field, type } = encoding; + switch (type) { + case 'nominal': { + const domain = map(data, field); + const scale = scaleOrdinal(colors.category).domain(domain); + return { + domain, + scale, + scaleType: 'ordinal', + range: colors.category, + }; + } + case 'ordinal': { + const domain = map(data, field).sort((a, b) => Number(a) - Number(b)); + const scale = scaleOrdinal(colors.sequential.scheme).domain(domain); + return { + domain, + scale, + scaleType: 'ordinal', + range: colors.sequential.scheme, + }; + } + case 'temporal': { + const timeStamps: number[] = data.map(obj => obj[field].getTime()); + const domain = getNumericDomain(timeStamps); + const scale = scaleSequential(colors.sequential.interpolator) + .domain(domain); + return { + domain, + scale, + scaleType: 'sequential', + }; + } + case 'quantitative': { + const values = data.map(row => Number(row[field])); + let domain = getNumericDomain(values); + const scale = scaleSequential(colors.sequential.interpolator).domain(domain); + return { + domain, + scale, + scaleType: 'sequential', + }; + } + default: { + throw Error('Invalid color encoding type.'); + } + } +}; + +/** + * computes and returns domain, range, color scale and value selector, + * which you can utitlize in Chart component. + */ + +export function getColorScale({ + colors, + encoding, + data, +}: ColorScaleArgs): ColorScale { + const { type, field } = encoding; + const { scale, scaleType, domain, range } = getColorScaleSetting({ colors, encoding, data }); + const getValue = (val: any) => val; + return { + scale, + domain, + type, + field, + getValue, + range, + scaleType, + selector: getRecordFieldSelector({ field, scale, getValue, scaleType }), + }; +} diff --git a/packages/graph/src/utils/getDataGroupByEncodings.ts b/packages/graph/src/utils/getDataGroupByEncodings.ts new file mode 100644 index 0000000..fab03b9 --- /dev/null +++ b/packages/graph/src/utils/getDataGroupByEncodings.ts @@ -0,0 +1,59 @@ +import { values, groupBy } from 'lodash-es'; +import { Encoding } from '../common/types'; + +/** + * It's a utility to split rows into different sub rows by multiple field. + * For example, given: + * const data = [ + * { value: 1, type: 'a', color: 'green' }, + * { value: 2, type: 'b', color: 'green' }, + * { value: 3, type: 'a', color: 'red' }, + * { value: 4, type: 'b', color: 'red' }, + * { value: 5, type: 'a', color: 'green' }, + * { value: 6, type: 'b', color: 'green' }, + * { value: 7, type: 'a', color: 'red' }, + * { value: 8, type: 'b', color: 'red' }, + * ] + * `getDataGroupByFields(data, ['type', 'color']) will categorize data by fields and return + * [ + * [ + * { value: 1, type: 'a', color: 'green' }, + * { value: 5, type: 'a', color: 'green' }, + * ], + * [ + * { value: 2, type: 'b', color: 'green' }, + * { value: 6, type: 'b', color: 'green' }, + * ], + * [ + * { value: 3, type: 'a', color: 'red' }, + * { value: 7, type: 'a', color: 'red' }, + * ], + * [ + * { value: 4 type: 'b', color: 'red' } + * { value: 8 type: 'b', color: 'red' } + * ], + * ] + */ + +export function getDataGroupByFields(data: object[], fields: string[]): object[][] { + return fields.reduce( + (all, field) => { + const groups = all.map(rows => values(groupBy(rows, field))); + return groups.reduce((joinedGroups, group) => [...joinedGroups, ...group], []); + }, + [data], + ); +} + +/** + * + * It's a utility to split rows into different sub rows by multiple encoding. + * It will get field name from encodings + * and apply `getdataGroupByFields` to build data group. + */ + +export function getDataGroupByEncodings(data: object[], encodings: Encoding[]): object[][] { + const fieldsToGroupBy: string[] = encodings + .map(encoding => encoding!.field); + return getDataGroupByFields(data, fieldsToGroupBy); +} diff --git a/packages/graph/src/utils/getRecordFieldSelector.ts b/packages/graph/src/utils/getRecordFieldSelector.ts index 4958585..45670f9 100644 --- a/packages/graph/src/utils/getRecordFieldSelector.ts +++ b/packages/graph/src/utils/getRecordFieldSelector.ts @@ -1,4 +1,4 @@ -import { AxisScale } from '../common/types'; +import { Scale } from '../common/types'; /** * Returns the data value selectors for a data record @@ -7,7 +7,7 @@ import { AxisScale } from '../common/types'; * @param fieldIndex - the current index of the field */ export function getRecordFieldSelector( - axis: Pick>, + axis: Pick>, ) { const { field, scale, getValue, scaleType } = axis;