diff --git a/package.json b/package.json index c6dfbb3e..6fa59baf 100755 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "array.prototype.flatmap": "^1.2.4", "classnames": "^2.2.6", "hot-formula-parser": "^4.0.0", + "tinygradient": "^1.1.2", "unistore": "^3.5.2" }, "devDependencies": { diff --git a/src/ColorScaleDataViewer.css b/src/ColorScaleDataViewer.css new file mode 100644 index 00000000..7b6bee3f --- /dev/null +++ b/src/ColorScaleDataViewer.css @@ -0,0 +1,5 @@ +.Spreadsheet__color-scale-data-viewer { + height: 100%; + display: flex; + align-items: center; +} diff --git a/src/ColorScaleDataViewer.tsx b/src/ColorScaleDataViewer.tsx new file mode 100644 index 00000000..32a57a37 --- /dev/null +++ b/src/ColorScaleDataViewer.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import { connect } from "unistore/react"; +import tinygradient from "tinygradient"; +import DataViewer from "./DataViewer"; +import * as Types from "./types"; +import "./ColorScaleDataViewer.css"; + +type ColorScalePoint = + | { color: string; type: "number"; value: number } + | { color: string; type: "percent"; value: number } + | { color: string; type: "percentile"; value: number }; + +type MinPoint = ColorScalePoint | { type: "minimum"; color: string }; +type MidPoint = ColorScalePoint | null; +type MaxPoint = ColorScalePoint | { type: "maximum"; color: string }; + +type Props = Types.DataViewerProps & { + columnMaxValue: number; + columnMinValue: number; + columnSize: number; + minPoint: MinPoint; + midPoint: MidPoint; + maxPoint: MaxPoint; +}; + +const resolveColor = (props: Props): string | undefined => { + const { + columnMaxValue, + columnMinValue, + minPoint, + midPoint, + maxPoint, + cell, + } = props; + if (!cell || !cell.value) { + return undefined; + } + const { value } = cell; + + const colors = midPoint + ? [ + { color: minPoint.color, pos: 0 }, + { color: midPoint.color, pos: midPoint.value }, + { color: maxPoint.color, pos: 1 }, + ] + : [ + { color: minPoint.color, pos: 0 }, + { color: maxPoint.color, pos: 1 }, + ]; + + const gradient = tinygradient(colors); + const relativeValue = + (value - columnMinValue) / (columnMaxValue - columnMinValue); + return gradient.rgbAt(relativeValue).toString(); +}; + +const ColorScaleDataViewer: React.FC = (props) => { + const color = resolveColor(props); + return ( +
+ +
+ ); +}; + +const mapStateToProps = (state: Types.StoreState, props: Props) => { + let columnMaxValue: number = state.data[0][props.column]?.value; + let columnMinValue: number = state.data[0][props.column]?.value; + for (const row of state.data) { + const cell = row[props.column]; + const value = cell && cell.value; + if (!value) { + continue; + } + columnMaxValue = columnMaxValue ? Math.max(value, columnMaxValue) : value; + columnMinValue = columnMinValue ? Math.min(value, columnMinValue) : value; + } + return { columnMaxValue, columnMinValue, columnSize: state.data.length }; +}; + +const createColorScaleDataViewer = ({ + minPoint, + maxPoint, + midPoint, +}: { + minPoint: MinPoint; + maxPoint: MaxPoint; + midPoint?: MidPoint; +}): React.FC => + // @ts-ignore + connect(mapStateToProps)((props) => { + return ( + + ); + }); + +export default createColorScaleDataViewer; diff --git a/src/DataViewer.css b/src/DataViewer.css new file mode 100644 index 00000000..e69de29b diff --git a/src/DataViewer.tsx b/src/DataViewer.tsx index 6b3e6928..8e29d8bd 100644 --- a/src/DataViewer.tsx +++ b/src/DataViewer.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import * as Types from "./types"; import { getComputedValue } from "./util"; -const toView = (value: React.ReactNode | boolean): React.ReactNode => { +const toView = (value: React.ReactNode | boolean): React.ReactElement => { if (value === false) { return FALSE; } @@ -15,7 +15,7 @@ const toView = (value: React.ReactNode | boolean): React.ReactNode => { const DataViewer = ({ cell, formulaParser, -}: Types.DataViewerProps): React.ReactNode => { +}: Types.DataViewerProps): React.ReactElement => { return toView(getComputedValue({ cell, formulaParser })); }; diff --git a/src/index.ts b/src/index.ts index 11d960bc..07e4e449 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export { default, default as Spreadsheet } from "./SpreadsheetStateProvider"; export type { Props } from "./SpreadsheetStateProvider"; export { createEmptyMatrix, getComputedValue } from "./util"; +export { default as createColorScaleDataViewer } from "./ColorScaleDataViewer"; export type { Matrix } from "./matrix"; export type { CellBase, diff --git a/src/stories/Spreadsheet.stories.tsx b/src/stories/Spreadsheet.stories.tsx index 763c1290..550a5329 100644 --- a/src/stories/Spreadsheet.stories.tsx +++ b/src/stories/Spreadsheet.stories.tsx @@ -1,6 +1,12 @@ import * as React from "react"; import { Story, Meta } from "@storybook/react/types-6-0"; -import { createEmptyMatrix, Spreadsheet, Props, CellBase } from ".."; +import { + createEmptyMatrix, + Spreadsheet, + Props, + CellBase, + createColorScaleDataViewer, +} from ".."; import * as Matrix from "../matrix"; import { AsyncCellDataEditor, AsyncCellDataViewer } from "./AsyncCellData"; import CustomCell from "./CustomCell"; @@ -144,9 +150,7 @@ export const WithCornerIndicator: Story> = (props) => ( ); export const Filter: Story> = (props) => { - const [data, setData] = React.useState( - EMPTY_DATA as Matrix.Matrix - ); + const [data, setData] = React.useState(EMPTY_DATA); const [filter, setFilter] = React.useState(""); const handleFilterChange = React.useCallback( @@ -165,7 +169,7 @@ export const Filter: Story> = (props) => { if (filter.length === 0) { return data; } - const filtered: Matrix.Matrix = []; + const filtered = createEmptyMatrix(0, 0); for (let row = 0; row < data.length; row++) { if (data.length !== 0) { for (let column = 0; column < data[0].length; column++) { @@ -202,3 +206,42 @@ export const Filter: Story> = (props) => { ); }; + +export const ColorScale: Story> = () => { + const data = createEmptyMatrix(INITIAL_ROWS, INITIAL_COLUMNS); + const GreenAndWhiteColorScaleDataViewer = createColorScaleDataViewer({ + minPoint: { type: "minimum", color: "#57BB8A" }, + maxPoint: { type: "maximum", color: "#FFFFFF" }, + }); + + const RedYellowGreenColorScaleDataViewer = createColorScaleDataViewer({ + minPoint: { type: "minimum", color: "#57BB8A" }, + midPoint: { type: "percent", color: "#FFD665", value: 0.5 }, + maxPoint: { type: "maximum", color: "#E67B73" }, + }); + + const UnbalanacedRedYellowGreenColorScaleDataViewer = createColorScaleDataViewer( + { + minPoint: { type: "minimum", color: "#57BB8A" }, + midPoint: { type: "percent", color: "#FFD665", value: 0.7 }, + maxPoint: { type: "maximum", color: "#E67B73" }, + } + ); + + for (let i = 0; i < INITIAL_ROWS; i++) { + data[i][0] = { + DataViewer: GreenAndWhiteColorScaleDataViewer, + value: i + 1, + }; + data[i][1] = { + DataViewer: RedYellowGreenColorScaleDataViewer, + value: i + 1, + }; + data[i][2] = { + DataViewer: UnbalanacedRedYellowGreenColorScaleDataViewer, + value: i + 1, + }; + } + + return ; +}; diff --git a/yarn.lock b/yarn.lock index da10fe89..d11ee62e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2834,6 +2834,11 @@ resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.6.tgz" integrity sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA== +"@types/tinycolor2@^1.4.0": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.2.tgz#721ca5c5d1a2988b4a886e35c2ffc5735b6afbdf" + integrity sha512-PeHg/AtdW6aaIO2a+98Xj7rWY4KC1E6yOy7AFknJQ7VXUGNrMlyxDFxJo7HqLtjQms/ZhhQX52mLVW/EX3JGOw== + "@types/uglify-js@*": version "3.11.1" resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.11.1.tgz" @@ -12762,11 +12767,19 @@ tiny-emitter@^2.0.0, tiny-emitter@^2.1.0: resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz" integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== -tinycolor2@^1.4.1: +tinycolor2@^1.0.0, tinycolor2@^1.4.1: version "1.4.2" resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz" integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== +tinygradient@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/tinygradient/-/tinygradient-1.1.2.tgz#95bcf18fb0d5db989c8a109ead8e5da9867b4e07" + integrity sha512-yIwbBfJOOHW3whamF00ZcxGWY794GNsAGjaCkDQJJNufXAcfUwbQwrVjwV1BKqArXFVg+JgMoECXFn/jfqSWsg== + dependencies: + "@types/tinycolor2" "^1.4.0" + tinycolor2 "^1.0.0" + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz"