From 143e3cef1eb46fef4150aa2f55bb9f1b8dc6a02b Mon Sep 17 00:00:00 2001 From: Niklavs Bariss Date: Fri, 3 Oct 2025 18:18:20 +0300 Subject: [PATCH 1/4] feat: Support threshold gradients --- README.md | 131 ++++++++++++++++++++++++++++++-------------- example/src/App.tsx | 11 +++- src/drawFunction.js | 103 ++++++++++++++++++++++++++++++---- src/types.ts | 12 +++- 4 files changed, 204 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 06781b0..236201e 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,13 @@ yarn add react-native-webview ## Quick Start ```tsx -import React, { useState, useMemo } from 'react'; -import { View } from 'react-native'; -import Chart from 'react-native-d3-chart'; +import React, { useState, useMemo } from 'react' +import { View } from 'react-native' +import Chart from 'react-native-d3-chart' export default function App() { - const [width, setWidth] = useState(0); - const height = width * 0.6; // 16:10 aspect ratio + const [width, setWidth] = useState(0) + const height = width * 0.6 // 16:10 aspect ratio // Generate some sample data const datasets = useMemo( @@ -61,7 +61,7 @@ export default function App() { }, ], [] - ); + ) const timeDomain = useMemo( () => ({ @@ -70,7 +70,7 @@ export default function App() { end: Date.now(), }), [] - ); + ) const colors = { background: '#fff', @@ -79,7 +79,7 @@ export default function App() { cursorStroke: '#0ff', highlightLabel: '#000', highlightTime: '#444', - }; + } return ( - ); + ) } ``` @@ -126,62 +126,74 @@ export default function App() { ```typescript type Dataset = { - measurementName: string; // Display name for this data series - color: string; // Hex color for the line and labels - points: Point[]; // Array of data points - unit: string; // Unit symbol (e.g., '°C', 'kg', 'm/s') - decimals: number; // Number of decimal places to show - minDeltaY?: number; // Minimum Y-axis change to show, limit Y-zoom - decimalSeparator?: '.' | ','; // Decimal separator + measurementName: string // Display name for this data series + color: string | ThresholdColor // Hex color for the line, or threshold-based coloring + points: Point[] // Array of data points + unit: string // Unit symbol (e.g., '°C', 'kg', 'm/s') + decimals: number // Number of decimal places to show + minDeltaY?: number // Minimum Y-axis change to show, limit Y-zoom + areaColor?: string // Optional area fill color (defaults to base color) + axisColor?: string // Optional Y-axis text color (defaults to base color) + decimalSeparator?: '.' | ',' // Decimal separator domain?: { // Custom Y-axis range - bottom: number; - top: number; - }; -}; + bottom: number + top: number + } +} + +type ThresholdColor = { + type: 'thresholds' + baseColor: string // Default color for values below all thresholds + gradientBlur: number // Gradient transition distance around thresholds + thresholds: Array<{ + value: number // Threshold value + color: string // Color to use above this value + }> // Should be sorted by value descending +} ``` #### Point ```typescript type Point = { - timestamp: number; // Unix timestamp in milliseconds - value: number | null; // Data value (null for gaps) -}; + timestamp: number // Unix timestamp in milliseconds + value: number | null // Data value (null for gaps) +} ``` #### TimeDomain ```typescript type TimeDomain = { - type: string; // Domain type (e.g., 'hour', 'day', 'week') - start: number; // Start timestamp (ms) - end: number; // End timestamp (ms) -}; + type: string // Domain type (e.g., 'hour', 'day', 'week') + start: number // Start timestamp (ms) + end: number // End timestamp (ms) +} ``` #### ChartColors ```typescript type ChartColors = { - background: string; // Chart background color - highlightLine: string; // Crosshair line color - border: string; // Chart border color - highlightLabel: string; // Value label text color - highlightTime: string; // Time label text color - cursorStroke: string; // Cursor/crosshair circle color -}; + background: string // Chart background color + highlightLine: string // Crosshair line color + border: string // Chart border color + highlightLabel: string // Value label text color + highlightTime: string // Time label text color + cursorStroke: string // Cursor/crosshair circle color +} ``` #### CalendarStrings ```typescript type CalendarStrings = { - days: string[]; // Full day names (Sunday first) - shortDays: string[]; // Short day names (Sun first) - months: string[]; // Full month names (January first) - shortMonths: string[]; // Short month names (Jan first) -}; + days: string[] // Full day names (Sunday first) + shortDays: string[] // Short day names (Sun first) + months: string[] // Full month names (January first) + shortMonths: string[] // Short month names (Jan first) +} ``` ## Advanced Usage @@ -204,9 +216,48 @@ const datasets = [ decimals: 0, points: humidityData, }, -]; +] ``` +### Threshold-Based Colors + +Create dynamic line colors that change based on data values using threshold configurations. This is perfect for showing status indicators, alerts, or different states in your data: + +```tsx +const datasetWithThresholds = { + measurementName: 'Server Load', + unit: '%', + decimals: 0, + areaColor: '#e78e96', // Optional: custom area fill color + color: { + type: 'thresholds', + baseColor: '#089851', // Green for values below all thresholds (low load) + gradientBlur: 50, // Smooth transition distance around thresholds + thresholds: [ + { value: 85, color: '#CF1E2E' }, // Red for values >= 85% (critical) + { value: 50, color: '#F29400' }, // Orange for values >= 50% (warning) + // Values < 50% will use baseColor (green) + ], + }, + points: serverLoadData, +} +``` + +**How it works:** + +- **Thresholds should be sorted by value in descending order** +- Values >= 85% will be colored red (`#CF1E2E`) - critical load +- Values >= 50% but < 80% will be colored orange (`#F29400`) - warning load +- Values < 50% will use the `baseColor` green (`#089851`) - healthy load +- The `gradientBlur` creates smooth color transitions around threshold boundaries + +**Real-world examples:** + +- **Temperature monitoring**: Blue (cold) → Green (optimal) → Red (overheating) +- **Performance metrics**: Red (poor) → Yellow (acceptable) → Green (excellent) +- **Battery levels**: Red (critical) → Orange (low) → Green (healthy) +- **Network latency**: Green (fast) → Yellow (moderate) → Red (slow) + ### Zoom Callbacks ```tsx diff --git a/example/src/App.tsx b/example/src/App.tsx index c093e96..b97c9ed 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -55,7 +55,16 @@ const measurementsRecords: Record = { unit: '°C', points: generateDataPoints(), decimals: 0, - color: '#e66', + areaColor: '#e78e96', + color: { + type: 'thresholds', + baseColor: '#CF1E2E', + gradientBlur: 50, + thresholds: [ + { value: 800, color: '#089851' }, + { value: 400, color: '#F29400' }, + ], + }, measurementName: Measurement.Red, }, [Measurement.Blue]: { diff --git a/src/drawFunction.js b/src/drawFunction.js index 8ad88d6..a3c2c6d 100644 --- a/src/drawFunction.js +++ b/src/drawFunction.js @@ -20,7 +20,7 @@ function sample(data, count, domain) { } const batchLength = (domainEnd - domainStart) / count - var i = data.findIndex(d => d.timestamp >= domainStart) + var i = data.findIndex((d) => d.timestamp >= domainStart) if (i < 0) return [] var batchEnd = @@ -133,6 +133,21 @@ function extractDate(d) { return d.date } +function getDatasetColor(dataset, value) { + if (dataset.color.type === 'thresholds') { + const crossedThreshold = dataset.color.thresholds.find( + (threshold) => value >= threshold.value + ) + + if (crossedThreshold) { + return crossedThreshold.color + } + + return dataset.color.baseColor + } + return dataset.color +} + function formatXAxis(g) { g.select('.domain').remove() g.selectAll('.tick text') @@ -396,7 +411,12 @@ window.draw = (props) => { .select('g#y_axis' + index) .transition() .call(buildYAxis(y, dataset, index)) - .call(fromatYAxis, color, index, width) + .call( + fromatYAxis, + dataset.axisColor ?? getDatasetColor(dataset), + index, + width + ) defs .append('linearGradient') @@ -419,7 +439,7 @@ window.draw = (props) => { .append('stop') .attr('offset', getGradientOffset(y)) .attr('stop-opacity', (d) => d.opacity) - .attr('stop-color', color) + .attr('stop-color', dataset.areaColor ?? getDatasetColor(dataset)) selectOrAppend(chart, 'path', 'area' + index) .datum(data) @@ -443,8 +463,46 @@ window.draw = (props) => { .attr('class', 'line') .attr('fill', 'none') .attr('stroke-width', 2) + + if (dataset.color.type === 'thresholds') { + const { baseColor, gradientBlur, thresholds } = dataset.color + const stops = [] + thresholds.forEach(({ value, color }, index) => { + stops.push( + { + value: value + gradientBlur, + color: color, + }, + { + value: value - gradientBlur, + color: thresholds[index + 1]?.color ?? baseColor, + } + ) + }) + + defs + .append('linearGradient') + .attr('id', 'line-gradient' + index) + .attr('gradientUnits', 'userSpaceOnUse') + .attr('x1', 0) + .attr('x2', 0) + .attr('y1', 0) + .attr('y2', '100%') + .selectAll('stop') + .data(stops) + .enter() + .append('stop') + .attr('offset', getGradientOffset(y)) + .attr('stop-color', (stop) => stop.color) + } + d3.select('path#line' + index) - .attr('stroke', color) + .attr( + 'stroke', + dataset.color.type === 'thresholds' + ? 'url(#line-gradient' + index + ')' + : dataset.color + ) .transition() .attr( 'd', @@ -498,7 +556,10 @@ window.draw = (props) => { .attr('y2', height) props.datasets.forEach( - ({ points, color, measurementName, unit }, index) => { + /* prettier-ignore newline */ + (dataset, index) => { + const { points, measurementName, unit } = dataset + const color = getDatasetColor(dataset) selectOrAppend(d3.select('#labels_holder'), 'span', 'label' + index) .style('color', colors.highlightLabel) .html(measurementName) @@ -515,9 +576,9 @@ window.draw = (props) => { ) .attr('r', 5) .attr('opacity', 0) - .attr('fill', color) + .attr('fill', 'transparent') .attr('stroke-width', 2) - .attr('stroke', colors.cursorStroke) + .attr('stroke', color) .attr('cx', width * highlightPosition) if (!highlightCroshair.attr('cy')) { @@ -526,7 +587,7 @@ window.draw = (props) => { const unitPositionKey = index % 2 === 0 ? 'right' : 'left' selectOrAppend(d3.select('div#my_dataviz'), 'span', 'unit' + index) - .style('color', color) + .style('color', dataset.axisColor ?? color) .style('font-size', '10px') .style('position', 'absolute') .style('bottom', margin.bottom - 5 + 'px') @@ -609,10 +670,21 @@ window.draw = (props) => { .transition() .duration(duration) .call(buildYAxis(y, dataset, index)) - .call(fromatYAxis, dataset.color, index, width) + .call( + fromatYAxis, + dataset.axisColor ?? getDatasetColor(dataset), + index, + width + ) defs - .select('linearGradient#area-gradient' + index) + .selectAll( + 'linearGradient#area-gradient' + + index + + ',' + + 'linearGradient#line-gradient' + + index + ) .selectAll('stop') .transition() .duration(duration) @@ -692,12 +764,14 @@ window.draw = (props) => { const highlightExactDate = x.invert(x0) var highlightTime = null - props.datasets.forEach(({ unit, decimals }, index) => { + props.datasets.forEach((dataset, index) => { + const { unit, decimals } = dataset const { definedData, y } = operators[index] if (!definedData.length) { valuesHolder .select('span#highlightvalue' + index) + .style('color', getDatasetColor(dataset)) .html(props.noDataString) return @@ -712,6 +786,11 @@ window.draw = (props) => { Math.abs(x0 - xValue) > 8 && Math.abs(highlightExactDate - highlight.date) > 10 * 60 * 1000 + const color = getDatasetColor( + dataset, + tooFar ? undefined : highlight.value + ) + if (!tooFar) { highlightTime = highlight.date } @@ -721,10 +800,12 @@ window.draw = (props) => { .duration(duration) .attr('cx', xValue) .attr('cy', y(highlight.value)) + .attr('stroke', color) .attr('opacity', tooFar ? 0 : 1) valuesHolder .select('span#highlightvalue' + index) + .style('color', color) .html( tooFar ? props.noDataString diff --git a/src/types.ts b/src/types.ts index cb44288..4bfd0bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,11 +18,21 @@ export type CalendarStrings = { export type Dataset = { measurementName: string - color: string + color: + | string + | { + type: 'thresholds' + baseColor: string + gradientBlur: number + /* should be sorted by value descending */ + thresholds: { value: number; color: string }[] + } points: Point[] unit: string decimals: number minDeltaY?: number + areaColor?: string + axisColor?: string decimalSeparator?: '.' | ',' domain?: { bottom: number; top: number } } From 63ee20d47e07bd56b24f45e80d1d7796a3524192 Mon Sep 17 00:00:00 2001 From: Niklavs Bariss Date: Mon, 6 Oct 2025 12:28:44 +0300 Subject: [PATCH 2/4] feat: Improve negative value support --- example/src/App.tsx | 36 +++++++++++++++++++++++------------- src/drawFunction.js | 10 ++++++---- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index b97c9ed..8a7414d 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -20,6 +20,7 @@ const chartColors: ChartProps['colors'] = { const generateDataPoints = ({ startingValue = 400, minimum = 0, + maximum = 3000, radomFactor = 20, } = {}) => { const points = [] @@ -31,9 +32,10 @@ const generateDataPoints = ({ const randomVariation = (Math.random() - 0.5) * radomFactor value += randomVariation - // randomVariation was negative and value went below minimum - if (value < minimum) { - // invert direction to keep above minimum + // either randomVariation was negative and value went below minimum + // or randomVariation was positive and value went above maximum + if (value < minimum || value > maximum) { + // invert direction to keep within bounds value -= 2 * randomVariation } @@ -44,28 +46,36 @@ const generateDataPoints = ({ } enum Measurement { - Red = 'Red', + Temperature = 'Temperature', Blue = 'Blue', Green = 'Green', Pink = 'Pink', } const measurementKeys = Object.values(Measurement) const measurementsRecords: Record = { - [Measurement.Red]: { + [Measurement.Temperature]: { unit: '°C', - points: generateDataPoints(), + points: generateDataPoints({ + maximum: 40, + minimum: -10, + radomFactor: 1, + startingValue: -8, + }), decimals: 0, - areaColor: '#e78e96', + areaColor: '#ff00ff', // '#83cba8', color: { type: 'thresholds', - baseColor: '#CF1E2E', - gradientBlur: 50, + baseColor: '#3d91ff', + gradientBlur: 2, thresholds: [ - { value: 800, color: '#089851' }, - { value: 400, color: '#F29400' }, + { value: 32, color: '#bb2222' }, + { value: 24, color: '#ffc400' }, + { value: 16, color: '#089851' }, + { value: 10, color: '#9ceeff' }, + { value: 0, color: '#00d5ff' }, ], }, - measurementName: Measurement.Red, + measurementName: Measurement.Temperature, }, [Measurement.Blue]: { unit: 'l', @@ -123,7 +133,7 @@ export default function App() { }, [timeDomainType]) const [enabledMeasurements, setEnabledMeasurements] = useState( - [Measurement.Red] + [Measurement.Temperature] ) const datasets = useMemo( diff --git a/src/drawFunction.js b/src/drawFunction.js index a3c2c6d..53ccc26 100644 --- a/src/drawFunction.js +++ b/src/drawFunction.js @@ -222,8 +222,10 @@ function getTickCount(domain, { minDeltaY }) { return tickCount } +// TODO: Check why was avoiding needed. Condionally maybe? +const avoidZeroAxisLabel = false function yFormat(value) { - if (value === 0) return '' + if (avoidZeroAxisLabel && value === 0) return '' const axisLabel = String(value) return axisLabel.length > yLabelMaxLength ? '' : axisLabel } @@ -454,7 +456,7 @@ window.draw = (props) => { .area() .defined(isDefined) .x((d) => x(d.date)) - .y0(y(0)) + .y0(y(y.domain()[0]) + 1) // Use min value, +1 to avoid blinking space .y1((d) => y(d.value)) ) @@ -661,7 +663,7 @@ window.draw = (props) => { .area() .defined(isDefined) .x((d) => x(d.date)) - .y0(y(0)) + .y0(y(y.domain()[0]) + 1) // Use min value, +1 to avoid blinking space .y1((d) => y(d.value)) ) @@ -734,7 +736,7 @@ window.draw = (props) => { .area() .defined(isDefined) .x((d) => newX(d.date)) - .y0(y(0)) + .y0(y(y.domain()[0]) + 1) // Use min value, +1 to avoid blinking space .y1((d) => y(d.value)) ) }) From cf8dd8127b8af191a8fc192212f924cf1863f3ef Mon Sep 17 00:00:00 2001 From: Niklavs Bariss Date: Mon, 6 Oct 2025 12:35:55 +0300 Subject: [PATCH 3/4] feat: Improve unit position on y-axis --- src/drawFunction.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/drawFunction.js b/src/drawFunction.js index 53ccc26..1eec0eb 100644 --- a/src/drawFunction.js +++ b/src/drawFunction.js @@ -222,10 +222,7 @@ function getTickCount(domain, { minDeltaY }) { return tickCount } -// TODO: Check why was avoiding needed. Condionally maybe? -const avoidZeroAxisLabel = false function yFormat(value) { - if (avoidZeroAxisLabel && value === 0) return '' const axisLabel = String(value) return axisLabel.length > yLabelMaxLength ? '' : axisLabel } @@ -592,7 +589,10 @@ window.draw = (props) => { .style('color', dataset.axisColor ?? color) .style('font-size', '10px') .style('position', 'absolute') - .style('bottom', margin.bottom - 5 + 'px') + .style( + 'bottom', + margin.bottom - 10 * (Math.floor(index / 2) + 1) + 'px' + ) .style( unitPositionKey, margin[unitPositionKey] + width + yLabelMargin + 'px' From 304bf378dd030e2af365fe3af9f81c0398f60244 Mon Sep 17 00:00:00 2001 From: Niklavs Bariss Date: Wed, 8 Oct 2025 11:02:15 +0300 Subject: [PATCH 4/4] Improve code - threshold gradients --- README.md | 16 ++++++++-------- example/src/App.tsx | 2 +- src/drawFunction.js | 32 +++++++++++++++----------------- src/types.ts | 25 ++++++++++++++++--------- 4 files changed, 40 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 236201e..4de8e19 100644 --- a/README.md +++ b/README.md @@ -145,11 +145,11 @@ type Dataset = { type ThresholdColor = { type: 'thresholds' baseColor: string // Default color for values below all thresholds - gradientBlur: number // Gradient transition distance around thresholds thresholds: Array<{ value: number // Threshold value color: string // Color to use above this value }> // Should be sorted by value descending + gradientBlur?: number // Gradient transition distance around thresholds. Default 0 - no blur } ``` @@ -231,11 +231,11 @@ const datasetWithThresholds = { areaColor: '#e78e96', // Optional: custom area fill color color: { type: 'thresholds', - baseColor: '#089851', // Green for values below all thresholds (low load) - gradientBlur: 50, // Smooth transition distance around thresholds + baseColor: '#00FF00', // Green for values below all thresholds (low load) + gradientBlur: 5, // Smooth transition distance around thresholds thresholds: [ - { value: 85, color: '#CF1E2E' }, // Red for values >= 85% (critical) - { value: 50, color: '#F29400' }, // Orange for values >= 50% (warning) + { value: 85, color: '#FF0000' }, // Red for values >= 85% (critical) + { value: 50, color: '#FF9400' }, // Orange for values >= 50% (warning) // Values < 50% will use baseColor (green) ], }, @@ -246,9 +246,9 @@ const datasetWithThresholds = { **How it works:** - **Thresholds should be sorted by value in descending order** -- Values >= 85% will be colored red (`#CF1E2E`) - critical load -- Values >= 50% but < 80% will be colored orange (`#F29400`) - warning load -- Values < 50% will use the `baseColor` green (`#089851`) - healthy load +- Values >= 85% will be colored red (`#FF0000`) - critical load +- Values >= 50% but < 80% will be colored orange (`#FF9400`) - warning load +- Values < 50% will use the `baseColor` green (`#00FF00`) - healthy load - The `gradientBlur` creates smooth color transitions around threshold boundaries **Real-world examples:** diff --git a/example/src/App.tsx b/example/src/App.tsx index 8a7414d..eadb0bb 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -62,7 +62,7 @@ const measurementsRecords: Record = { startingValue: -8, }), decimals: 0, - areaColor: '#ff00ff', // '#83cba8', + areaColor: '#83cba8', color: { type: 'thresholds', baseColor: '#3d91ff', diff --git a/src/drawFunction.js b/src/drawFunction.js index 1eec0eb..115cd1e 100644 --- a/src/drawFunction.js +++ b/src/drawFunction.js @@ -453,7 +453,7 @@ window.draw = (props) => { .area() .defined(isDefined) .x((d) => x(d.date)) - .y0(y(y.domain()[0]) + 1) // Use min value, +1 to avoid blinking space + .y0(y(y.domain()[0]) + 1) // NOTE: +1 prevents a weird hairline at the bottom (just above axis) .y1((d) => y(d.value)) ) @@ -464,20 +464,18 @@ window.draw = (props) => { .attr('stroke-width', 2) if (dataset.color.type === 'thresholds') { - const { baseColor, gradientBlur, thresholds } = dataset.color - const stops = [] - thresholds.forEach(({ value, color }, index) => { - stops.push( - { - value: value + gradientBlur, - color: color, - }, - { - value: value - gradientBlur, - color: thresholds[index + 1]?.color ?? baseColor, - } - ) - }) + const { baseColor, gradientBlur = 0, thresholds } = dataset.color + const stops = thresholds.flatMap(({ value, color }, index) => [ + { + value: value + gradientBlur, + color: color, + }, + { + value: value - gradientBlur, + // NOTE: All thresholds have color. index out of bounds - last threshold - use baseColor + color: thresholds[index + 1]?.color ?? baseColor, + }, + ]) defs .append('linearGradient') @@ -663,7 +661,7 @@ window.draw = (props) => { .area() .defined(isDefined) .x((d) => x(d.date)) - .y0(y(y.domain()[0]) + 1) // Use min value, +1 to avoid blinking space + .y0(y(y.domain()[0]) + 1) // NOTE: +1 prevents a weird hairline at the bottom (just above axis) .y1((d) => y(d.value)) ) @@ -736,7 +734,7 @@ window.draw = (props) => { .area() .defined(isDefined) .x((d) => newX(d.date)) - .y0(y(y.domain()[0]) + 1) // Use min value, +1 to avoid blinking space + .y0(y(y.domain()[0]) + 1) // NOTE: +1 prevents a weird hairline at the bottom (just above axis) .y1((d) => y(d.value)) ) }) diff --git a/src/types.ts b/src/types.ts index 4bfd0bf..2879496 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,17 +16,24 @@ export type CalendarStrings = { shortMonths: string[] } +type ThresholdColor = { + type: 'thresholds' + baseColor: string + /** + * Must be sorted by value descending (highest value first) + */ + thresholds: { value: number; color: string }[] + /** + * Gradient transition distance around thresholds. + * Must be non-negative. Should be less than half the distance between thresholds. + * @default 0 - no blur + */ + gradientBlur?: number +} + export type Dataset = { measurementName: string - color: - | string - | { - type: 'thresholds' - baseColor: string - gradientBlur: number - /* should be sorted by value descending */ - thresholds: { value: number; color: string }[] - } + color: string | ThresholdColor points: Point[] unit: string decimals: number