Replies: 1 comment
-
And if you're curious, here is some example code I am using to test it out. I initiate an array of 10,000 random values, but display only 1000 of them at a time. 30 fps import React, { useState, useCallback } from "react";
import type { LayoutChangeEvent } from "react-native";
import { View } from "react-native";
import { Canvas, Path } from "@shopify/react-native-skia";
import {
useSharedValue,
useDerivedValue,
useFrameCallback,
} from "react-native-reanimated";
import type { SharedValue } from "react-native-reanimated";
import { SkPath, Skia, SkPoint } from "@shopify/react-native-skia";
import type { GraphPoint, GraphRange } from "./LineGraphProps";
const PIXEL_RATIO = 1;
const DEFAULT_MIN_X = 0;
const DEFAULT_MAX_X = 10000;
const lineThickness = 2;
const DEFAULT_COLOR = "#00ff00"; // Default color if not provided
var RAW_DATA = Array.from({ length: 10000 }).map((_, i) => ({
x: i * 16,
y: Math.floor(Math.random() * 100) + 1,
}));
export interface GraphXRange {
min: Date;
max: Date;
}
export interface GraphYRange {
min: number;
max: number;
}
export interface GraphPathRange {
x: GraphXRange;
y: GraphYRange;
}
type GraphPathConfig = {
pointsInRange: GraphPoint[];
horizontalPadding: number;
verticalPadding: number;
canvasHeight: number;
canvasWidth: number;
range: GraphPathRange;
};
type GraphPathConfigWithGradient = GraphPathConfig & {
shouldFillGradient: true;
};
type GraphPathConfigWithoutGradient = GraphPathConfig & {
shouldFillGradient: false;
};
export function getGraphPathRange(
points: GraphPoint[],
range?: GraphRange
): GraphPathRange {
const minValueX = range?.x?.min ?? points[0]?.date ?? new Date();
const maxValueX =
range?.x?.max ?? points[points.length - 1]?.date ?? new Date();
const minValueY =
range?.y?.min ??
points.reduce(
(prev, curr) => (curr.value < prev ? curr.value : prev),
Number.MAX_SAFE_INTEGER
);
const maxValueY =
range?.y?.max ??
points.reduce(
(prev, curr) => (curr.value > prev ? curr.value : prev),
Number.MIN_SAFE_INTEGER
);
return {
x: { min: minValueX, max: maxValueX },
y: { min: minValueY, max: maxValueY },
};
}
export const getXPositionInRange = (x: number, xRange: GraphXRange): number => {
"worklet";
const diff = xRange.max - xRange.min;
return (x - xRange.min) / diff;
};
export const getXInRange = (
width: number,
date: Date,
xRange: GraphXRange
): number => {
"worklet";
return Math.floor(width * getXPositionInRange(date, xRange));
};
export const getYPositionInRange = (
value: number,
yRange: GraphYRange
): number => {
"worklet";
const diff = yRange.max - yRange.min;
const y = value;
return (y - yRange.min) / diff;
};
export const getYInRange = (
height: number,
value: number,
yRange: GraphYRange
): number => {
"worklet";
return Math.floor(height * getYPositionInRange(value, yRange));
};
export const getPointsInRange = (
allPoints: GraphPoint[],
range: GraphPathRange
) => {
return allPoints.filter((point) => {
const portionFactorX = getXPositionInRange(point.date, range.x);
return portionFactorX <= 1 && portionFactorX >= 0;
});
};
type GraphPathWithGradient = { path: SkPath; gradientPath: SkPath };
function createGraphPathBase(
props: GraphPathConfigWithGradient
): GraphPathWithGradient;
function createGraphPathBase(props: GraphPathConfigWithoutGradient): SkPath;
const getGraphDataIndex = (pixel: number, startX, endX, n) => {
"worklet";
return Math.round(((pixel - startX) / (endX - startX)) * (n - 1));
};
const getNextPixelValue = (pixel: number, endX: number) => {
"worklet";
if (pixel === endX || pixel + PIXEL_RATIO < endX) return pixel + PIXEL_RATIO;
return endX;
};
function createGraphPathBase({
pointsInRange: graphData,
range,
horizontalPadding,
verticalPadding,
canvasHeight: height,
canvasWidth: width,
shouldFillGradient,
}: GraphPathConfigWithGradient | GraphPathConfigWithoutGradient):
| SkPath
| GraphPathWithGradient {
"worklet";
// var starttime = Date.now();
const path = Skia.Path.Make();
// Canvas width substracted by the horizontal padding => Actual drawing width
const drawingWidth = width - 2 * horizontalPadding;
// Canvas height substracted by the vertical padding => Actual drawing height
const drawingHeight = height - 2 * verticalPadding;
if (graphData[0] == null) return path;
const points: SkPoint[] = [];
const startX =
getXInRange(drawingWidth, graphData[0]!.x, range.x) + horizontalPadding;
const endX =
getXInRange(drawingWidth, graphData[graphData.length - 1]!.x, range.x) +
horizontalPadding;
for (
let pixel = startX;
startX <= pixel && pixel <= endX;
pixel = getNextPixelValue(pixel, endX)
) {
const index = getGraphDataIndex(pixel, startX, endX, graphData.length);
// Draw first point only on the very first pixel
if (index === 0 && pixel !== startX) continue;
// Draw last point only on the very last pixel
if (index === graphData.length - 1 && pixel !== endX) continue;
if (index !== 0 && index !== graphData.length - 1) {
// Only draw point, when the point is exact
const exactPointX =
getXInRange(drawingWidth, graphData[index]!.x, range.x) +
horizontalPadding;
const isExactPointInsidePixelRatio = Array(PIXEL_RATIO)
.fill(0)
.some((_value, additionalPixel) => {
return pixel + additionalPixel === exactPointX;
});
if (!isExactPointInsidePixelRatio) continue;
}
const value = graphData[index]!.y;
const y =
drawingHeight -
getYInRange(drawingHeight, value, range.y) +
verticalPadding;
points.push({ x: pixel, y: y });
}
for (let i = 0; i < points.length; i++) {
const point = points[i]!;
// first point needs to start the path
if (i === 0) path.moveTo(point.x, point.y);
const prev = points[i - 1];
const prevPrev = points[i - 2];
if (prev == null) continue;
const p0 = prevPrev ?? prev;
const p1 = prev;
const cp1x = (2 * p0.x + p1.x) / 3;
const cp1y = (2 * p0.y + p1.y) / 3;
const cp2x = (p0.x + 2 * p1.x) / 3;
const cp2y = (p0.y + 2 * p1.y) / 3;
const cp3x = (p0.x + 4 * p1.x + point.x) / 6;
const cp3y = (p0.y + 4 * p1.y + point.y) / 6;
path.cubicTo(cp1x, cp1y, cp2x, cp2y, cp3x, cp3y);
// path.lineTo(point.x, point.y);
if (i === points.length - 1) {
path.cubicTo(point.x, point.y, point.x, point.y, point.x, point.y);
}
}
// console.log("Latency:", Date.now() - starttime, "ms");
if (!shouldFillGradient) return path;
const gradientPath = path.copy();
gradientPath.lineTo(endX, height + verticalPadding);
gradientPath.lineTo(0 + horizontalPadding, height + verticalPadding);
return { path: path, gradientPath: gradientPath };
}
export function createGraphPath(props: GraphPathConfig): SkPath {
"worklet";
return createGraphPathBase({ ...props, shouldFillGradient: false });
}
export function createGraphPathWithGradient(
props: GraphPathConfig
): GraphPathWithGradient {
return createGraphPathBase({
...props,
shouldFillGradient: true,
});
}
// Update animate to use cached index bounds for slicing
export const animate = (
data: any,
rangeXMin: SharedValue<number>,
rangeXMax: SharedValue<number>,
mostRecentMin: SharedValue<number>,
mostRecentMax: SharedValue<number>
) => {
"worklet";
// advance the x-range
const newMin = rangeXMin.value + 10;
const newMax = rangeXMax.value + 10;
rangeXMin.value = newMin;
rangeXMax.value = newMax;
// compute new slice with cached bounds
let startIdx = mostRecentMin.value;
while (startIdx < RAW_DATA.length && RAW_DATA[startIdx].x < newMin)
startIdx++;
let endIdx = mostRecentMax.value;
while (endIdx < RAW_DATA.length && RAW_DATA[endIdx].x <= newMax) endIdx++;
data.value = RAW_DATA.slice(startIdx, endIdx);
// update cache indices
mostRecentMin.value = startIdx;
mostRecentMax.value = endIdx;
// console.log("latency:", Date.now() - startTime, "ms");
};
export default function Chart() {
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
// initialize cached index for max x range
const initialMaxIdx = React.useMemo(
() => RAW_DATA.findIndex((p) => p.x > DEFAULT_MAX_X) ?? RAW_DATA.length,
[]
);
const rangeXMin = useSharedValue(DEFAULT_MIN_X);
const rangeXMax = useSharedValue(DEFAULT_MAX_X);
const mostRecentMin = useSharedValue(0);
const mostRecentMax = useSharedValue(initialMaxIdx);
const rawPoints = useSharedValue(RAW_DATA.slice(0, initialMaxIdx));
// Add throttle for 30 FPS
const fpsInterval = 1000 / 30;
const frameAccumulator = useSharedValue(0);
useFrameCallback((frame) => {
if (!frame.timeSincePreviousFrame) return;
// accumulate elapsed time
frameAccumulator.value += frame.timeSincePreviousFrame;
// only run animate when enough time (fpsInterval) has passed
if (frameAccumulator.value < fpsInterval) return;
// subtract the interval to preserve leftover time
frameAccumulator.value %= fpsInterval;
// call animate with cached index bounds
animate(rawPoints, rangeXMin, rangeXMax, mostRecentMin, mostRecentMax);
});
// fix onLayout implicit any
const onLayout = useCallback((e: LayoutChangeEvent) => {
const { layout } = e.nativeEvent;
setWidth(Math.round(layout.width));
setHeight(Math.round(layout.height));
}, []);
const path = useDerivedValue(
() =>
createGraphPath({
pointsInRange: rawPoints.value,
range: {
x: {
min: new Date(rangeXMin.value),
max: new Date(rangeXMax.value),
},
y: { min: 0, max: 100 },
},
canvasHeight: height,
canvasWidth: width,
horizontalPadding: lineThickness,
verticalPadding: lineThickness,
}),
[height, lineThickness, width]
);
return (
<View style={{ width: 1000, height: 200 }} onLayout={onLayout}>
<Canvas style={{ height: 200, width: 1000 }}>
<Path
path={path}
strokeWidth={lineThickness}
color={DEFAULT_COLOR}
style="stroke"
strokeJoin="round"
strokeCap="round"
></Path>
</Canvas>
</View>
);
} |
Beta Was this translation helpful? Give feedback.
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
I am testing out using react-native-skia for having multiple plots show real-time, streaming data. Each plot has 1000 datapoints, with a new data point coming in every 10 - 16 ms, but 30 fps for data visualization is fine.
One graph works fine, but having 3-4 graphs running at the same time starts to lag a bit. Is this expected? I am trying to understand the upper limit of skia, so I know my implementation is efficient.
Beta Was this translation helpful? Give feedback.
All reactions