refactor(api): share sparkline path and luminance helpers between web and native

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-05 04:27:15 -04:00
1 parent 4b38303404
commit 88e777d5ce
5 files changed
+88 -151

No files matched your search

+3 -70
View File
@@ -3,80 +3,13 @@ import { useCallback, useState } from "react";
import { StyleSheet, View, type LayoutChangeEvent } from "react-native";
import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg";
import { computeMonotonePath, type SparklinePoint } from "@sofa/api/utils";
interface SparklineProps {
data: Array<{ bucket: string; count: number }>;
color: string;
}
interface Point {
x: number;
y: number;
}
/**
* Fritsch-Carlson monotone cubic interpolation.
* Produces smooth SVG paths that never overshoot between data points.
*/
function computeMonotonePath(
points: Point[],
height: number,
): { strokePath: string; areaPath: string } | null {
const n = points.length;
if (n < 2) return null;
// Step 1: compute secants between adjacent points
const deltas: number[] = [];
for (let k = 0; k < n - 1; k++) {
const dx = points[k + 1].x - points[k].x;
deltas[k] = dx === 0 ? 0 : (points[k + 1].y - points[k].y) / dx;
}
// Step 2: compute initial tangent slopes
const tangents: number[] = Array.from({ length: n });
tangents[0] = deltas[0];
tangents[n - 1] = deltas[n - 2];
for (let k = 1; k < n - 1; k++) {
if (Math.sign(deltas[k - 1]) !== Math.sign(deltas[k])) {
tangents[k] = 0;
} else {
tangents[k] = (deltas[k - 1] + deltas[k]) / 2;
}
}
// Step 3: Fritsch-Carlson monotonicity fix
for (let k = 0; k < n - 1; k++) {
if (deltas[k] === 0) {
tangents[k] = 0;
tangents[k + 1] = 0;
} else {
const alpha = tangents[k] / deltas[k];
const beta = tangents[k + 1] / deltas[k];
const s = alpha * alpha + beta * beta;
if (s > 9) {
const tau = 3 / Math.sqrt(s);
tangents[k] = tau * alpha * deltas[k];
tangents[k + 1] = tau * beta * deltas[k];
}
}
}
// Step 4: build SVG path with cubic Bezier segments
let strokePath = `M${points[0].x},${points[0].y}`;
for (let k = 0; k < n - 1; k++) {
const dx = points[k + 1].x - points[k].x;
const cp1x = points[k].x + dx / 3;
const cp1y = points[k].y + (tangents[k] * dx) / 3;
const cp2x = points[k + 1].x - dx / 3;
const cp2y = points[k + 1].y - (tangents[k + 1] * dx) / 3;
strokePath += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${points[k + 1].x},${points[k + 1].y}`;
}
// Close the area path along the bottom edge
const areaPath = `${strokePath} L${points[n - 1].x},${height} L${points[0].x},${height} Z`;
return { strokePath, areaPath };
}
export function Sparkline({ data, color }: SparklineProps) {
const uniqueId = useId();
const gradientId = `sparkline-${uniqueId.replace(/:/g, "")}`;
@@ -99,7 +32,7 @@ export function Sparkline({ data, color }: SparklineProps) {
const n = data.length;
const topPadding = size.height * 0.05;
const points: Point[] = [];
const points: SparklinePoint[] = [];
for (let i = 0; i < n; i++) {
const x = n === 1 ? size.width / 2 : (i / (n - 1)) * size.width;
const y = topPadding + (1 - counts[i] / maxCount) * (size.height - topPadding);
+1 -8
View File
@@ -2,14 +2,7 @@ import { useEffect } from "react";
import { Uniwind } from "uniwind";
import type { ColorPalette } from "@sofa/api/schemas";
function hexToRelativeLuminance(hex: string): number {
const r = Number.parseInt(hex.slice(1, 3), 16) / 255;
const g = Number.parseInt(hex.slice(3, 5), 16) / 255;
const b = Number.parseInt(hex.slice(5, 7), 16) / 255;
const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
import { hexToRelativeLuminance } from "@sofa/api/utils";
export function useTitleTheme(palette: ColorPalette | null | undefined): void {
const vibrant = palette?.vibrant ?? null;
@@ -1,74 +1,12 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { computeMonotonePath, type SparklinePoint } from "@sofa/api/utils";
interface SparklineProps {
data: Array<{ bucket: string; count: number }>;
color: string;
}
interface Point {
x: number;
y: number;
}
/**
* Fritsch-Carlson monotone cubic interpolation.
* Produces smooth SVG paths that never overshoot between data points.
*/
function computeMonotonePath(
points: Point[],
height: number,
): { strokePath: string; areaPath: string } | null {
const n = points.length;
if (n < 2) return null;
const deltas: number[] = [];
for (let k = 0; k < n - 1; k++) {
const dx = points[k + 1].x - points[k].x;
deltas[k] = dx === 0 ? 0 : (points[k + 1].y - points[k].y) / dx;
}
const tangents: number[] = Array.from({ length: n });
tangents[0] = deltas[0];
tangents[n - 1] = deltas[n - 2];
for (let k = 1; k < n - 1; k++) {
if (Math.sign(deltas[k - 1]) !== Math.sign(deltas[k])) {
tangents[k] = 0;
} else {
tangents[k] = (deltas[k - 1] + deltas[k]) / 2;
}
}
for (let k = 0; k < n - 1; k++) {
if (deltas[k] === 0) {
tangents[k] = 0;
tangents[k + 1] = 0;
} else {
const alpha = tangents[k] / deltas[k];
const beta = tangents[k + 1] / deltas[k];
const s = alpha * alpha + beta * beta;
if (s > 9) {
const tau = 3 / Math.sqrt(s);
tangents[k] = tau * alpha * deltas[k];
tangents[k + 1] = tau * beta * deltas[k];
}
}
}
let strokePath = `M${points[0].x},${points[0].y}`;
for (let k = 0; k < n - 1; k++) {
const dx = points[k + 1].x - points[k].x;
const cp1x = points[k].x + dx / 3;
const cp1y = points[k].y + (tangents[k] * dx) / 3;
const cp2x = points[k + 1].x - dx / 3;
const cp2y = points[k + 1].y - (tangents[k + 1] * dx) / 3;
strokePath += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${points[k + 1].x},${points[k + 1].y}`;
}
const areaPath = `${strokePath} L${points[n - 1].x},${height} L${points[0].x},${height} Z`;
return { strokePath, areaPath };
}
export function Sparkline({ data, color }: SparklineProps) {
const uniqueId = useId();
const gradientId = `sparkline-${uniqueId.replace(/:/g, "")}`;
@@ -99,7 +37,7 @@ export function Sparkline({ data, color }: SparklineProps) {
const n = data.length;
const topPadding = size.height * 0.05;
const points: Point[] = [];
const points: SparklinePoint[] = [];
for (let i = 0; i < n; i++) {
const x = n === 1 ? size.width / 2 : (i / (n - 1)) * size.width;
const y = topPadding + (1 - counts[i] / maxCount) * (size.height - topPadding);
+2 -8
View File
@@ -1,13 +1,7 @@
import type { ColorPalette } from "@sofa/api/schemas";
import { hexToRelativeLuminance } from "@sofa/api/utils";
/** @internal */
export function hexToRelativeLuminance(hex: string): number {
const r = Number.parseInt(hex.slice(1, 3), 16) / 255;
const g = Number.parseInt(hex.slice(3, 5), 16) / 255;
const b = Number.parseInt(hex.slice(5, 7), 16) / 255;
const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
export { hexToRelativeLuminance } from "@sofa/api/utils";
export function getThemeCssProperties(palette: ColorPalette | null): React.CSSProperties {
const color = palette?.vibrant;
+79
View File
@@ -57,3 +57,82 @@ export function getNextEpisode(
return { nextEpisode, totalEpisodes, watchedEpisodes };
}
/** A 2D point on a sparkline. */
export interface SparklinePoint {
x: number;
y: number;
}
/**
* Fritsch-Carlson monotone cubic interpolation.
* Produces smooth SVG paths that never overshoot between data points.
*/
export function computeMonotonePath(
points: SparklinePoint[],
height: number,
): { strokePath: string; areaPath: string } | null {
const n = points.length;
if (n < 2) return null;
// Step 1: compute secants between adjacent points
const deltas: number[] = [];
for (let k = 0; k < n - 1; k++) {
const dx = points[k + 1].x - points[k].x;
deltas[k] = dx === 0 ? 0 : (points[k + 1].y - points[k].y) / dx;
}
// Step 2: compute initial tangent slopes
const tangents: number[] = Array.from({ length: n });
tangents[0] = deltas[0];
tangents[n - 1] = deltas[n - 2];
for (let k = 1; k < n - 1; k++) {
if (Math.sign(deltas[k - 1]) !== Math.sign(deltas[k])) {
tangents[k] = 0;
} else {
tangents[k] = (deltas[k - 1] + deltas[k]) / 2;
}
}
// Step 3: Fritsch-Carlson monotonicity fix
for (let k = 0; k < n - 1; k++) {
if (deltas[k] === 0) {
tangents[k] = 0;
tangents[k + 1] = 0;
} else {
const alpha = tangents[k] / deltas[k];
const beta = tangents[k + 1] / deltas[k];
const s = alpha * alpha + beta * beta;
if (s > 9) {
const tau = 3 / Math.sqrt(s);
tangents[k] = tau * alpha * deltas[k];
tangents[k + 1] = tau * beta * deltas[k];
}
}
}
// Step 4: build SVG path with cubic Bezier segments
let strokePath = `M${points[0].x},${points[0].y}`;
for (let k = 0; k < n - 1; k++) {
const dx = points[k + 1].x - points[k].x;
const cp1x = points[k].x + dx / 3;
const cp1y = points[k].y + (tangents[k] * dx) / 3;
const cp2x = points[k + 1].x - dx / 3;
const cp2y = points[k + 1].y - (tangents[k + 1] * dx) / 3;
strokePath += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${points[k + 1].x},${points[k + 1].y}`;
}
// Close the area path along the bottom edge
const areaPath = `${strokePath} L${points[n - 1].x},${height} L${points[0].x},${height} Z`;
return { strokePath, areaPath };
}
/** WCAG relative luminance (0–1) of a `#rrggbb` hex color. */
export function hexToRelativeLuminance(hex: string): number {
const r = Number.parseInt(hex.slice(1, 3), 16) / 255;
const g = Number.parseInt(hex.slice(3, 5), 16) / 255;
const b = Number.parseInt(hex.slice(5, 7), 16) / 255;
const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}