mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
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:
5 files changed
+88
-151
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user