diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml new file mode 100644 index 00000000..aa6b55de --- /dev/null +++ b/.github/workflows/site.yml @@ -0,0 +1,47 @@ +name: Site + +on: + workflow_dispatch: + push: + branches: + - master + - next + paths: + - ".github/workflows/site.yml" + - "apps/site/**" + - "docs/**" + - "packages/**" + - "package-lock.json" + - "package.json" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Install Pro docs package + env: + CHART_KIT_PRO_NPM_TOKEN: ${{ secrets.CHART_KIT_PRO_NPM_TOKEN }} + run: | + if [ -n "$CHART_KIT_PRO_NPM_TOKEN" ]; then + npm config set //registry.npmjs.org/:_authToken "$CHART_KIT_PRO_NPM_TOKEN" + npm install --no-save --package-lock=false --ignore-scripts @chart-kit/pro + echo "CHART_KIT_PRO_DOCS=true" >> "$GITHUB_ENV" + else + echo "CHART_KIT_PRO_NPM_TOKEN is not set; building with local Pro docs stubs." + fi + + - name: Build site + run: npm run site:build diff --git a/apps/site/astro.config.mjs b/apps/site/astro.config.mjs index cce9601a..1c265300 100644 --- a/apps/site/astro.config.mjs +++ b/apps/site/astro.config.mjs @@ -8,6 +8,7 @@ import { chartKitMarkdownPatches } from "./src/lib/starlight-markdown-patches.mj import tailwindcss from "@tailwindcss/vite"; const repositoryUrl = "https://github.com/indiespirit/react-native-chart-kit"; +const docsSlug = (slug) => `docs/react-native/${slug}`; const packageSource = (packagePath) => fileURLToPath(new URL(`../../packages/${packagePath}`, import.meta.url)); const nodeModuleSource = (packagePath) => @@ -24,6 +25,15 @@ const expoVectorIconsStub = localSource( const svgTransformParserStub = localSource( "./src/previews/svgTransformParserStub.ts" ); +const useRealProCharts = process.env.CHART_KIT_PRO_DOCS === "true"; +const chartKitProAliases = useRealProCharts + ? [] + : [ + { + find: /^@chart-kit\/pro$/, + replacement: localSource("./src/previews/proStub.tsx") + } + ]; const chartKitPreviewWebAliases = () => ({ name: "chart-kit-preview-web-aliases", @@ -77,32 +87,45 @@ export default defineConfig({ sidebar: [ { label: "Start", - items: [{ slug: "docs/getting-started/installation" }] + items: [ + { slug: docsSlug("getting-started/installation") }, + { slug: docsSlug("getting-started/contributing") } + ] }, { label: "Charts", items: [ - { slug: "docs/charts/line-and-area" }, - { slug: "docs/charts/bar" }, - { slug: "docs/charts/pie-and-donut" }, - { slug: "docs/charts/progress" }, - { slug: "docs/charts/contribution-heatmap" } + { slug: docsSlug("charts/line") }, + { slug: docsSlug("charts/area") }, + { slug: docsSlug("charts/bar") }, + { slug: docsSlug("charts/pie") }, + { slug: docsSlug("charts/donut") }, + { slug: docsSlug("charts/progress") }, + { slug: docsSlug("charts/contribution-heatmap") } + ] + }, + { + label: "Pro Charts", + items: [ + { slug: docsSlug("charts/candlebar") }, + { slug: docsSlug("charts/radar") }, + { slug: docsSlug("charts/combo") } ] }, { label: "Guides", items: [ - { slug: "docs/charts/themes" }, - { slug: "docs/charts/accessibility" }, - { slug: "docs/troubleshooting" }, - { slug: "docs/recipes" } + { slug: docsSlug("charts/themes") }, + { slug: docsSlug("charts/accessibility") }, + { slug: docsSlug("troubleshooting") }, + { slug: docsSlug("recipes") } ] }, { label: "Migration", items: [ - { slug: "docs/migration/from-v1" }, - { slug: "docs/migration/prop-mapping" } + { slug: docsSlug("migration/from-v1") }, + { slug: docsSlug("migration/prop-mapping") } ] } ], @@ -119,6 +142,7 @@ export default defineConfig({ "react-native", "react-native-chart-kit", "react-native-chart-kit/v2", + "@chart-kit/pro", "react-native-gesture-handler", "react-native-svg" ] @@ -141,6 +165,7 @@ export default defineConfig({ find: /^@chart-kit\/svg-renderer$/, replacement: packageSource("svg-renderer/src/index.ts") }, + ...chartKitProAliases, { find: /^react-native$/, replacement: reactNativeWebStub diff --git a/apps/site/package.json b/apps/site/package.json index dc13ef3c..bb6ed3f9 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -22,6 +22,7 @@ "dependencies": { "@tailwindcss/vite": "^4.3.0", "lucide-astro": "^0.556.0", + "react-live": "^4.1.8", "tailwindcss": "^4.3.0" } } diff --git a/apps/site/src/chart-theme-controls.ts b/apps/site/src/chart-theme-controls.ts new file mode 100644 index 00000000..d24ed01d --- /dev/null +++ b/apps/site/src/chart-theme-controls.ts @@ -0,0 +1,218 @@ +import { + applyChartThemePreset, + chartThemeOptions, + getCurrentChartThemePreset, + isChartThemePreset +} from "./previews/chartTheme"; + +type ColorTheme = "dark" | "light"; + +const controlSelector = "[data-chart-theme-control]"; +const docsThemeToggleSelector = "[data-docs-theme-toggle]"; +const selectSelector = "[data-chart-theme-select]"; +const starlightThemeStorageKey = "starlight-theme"; +const themeColor = { + dark: "#06070a", + light: "#f7f8fb" +}; + +const isColorTheme = (value: string | null): value is ColorTheme => + value === "dark" || value === "light"; + +const getCurrentColorTheme = (): ColorTheme => { + try { + const stored = localStorage.getItem(starlightThemeStorageKey); + + if (isColorTheme(stored)) { + return stored; + } + } catch { + // Storage can be blocked in restricted browser contexts. + } + + return document.documentElement.dataset.theme === "light" ? "light" : "dark"; +}; + +const syncStarlightThemeSelects = (theme: ColorTheme) => { + document + .querySelectorAll("starlight-theme-select select") + .forEach((select) => { + if (select.value !== theme) { + select.value = theme; + } + }); +}; + +const syncDocsThemeToggles = (theme: ColorTheme) => { + const nextTheme = theme === "dark" ? "light" : "dark"; + + document + .querySelectorAll(docsThemeToggleSelector) + .forEach((button) => { + button.dataset.themeCurrent = theme; + button.dataset.themeNext = nextTheme; + button.setAttribute("aria-label", `Switch to ${nextTheme} mode`); + button.setAttribute("title", `Switch to ${nextTheme} mode`); + }); +}; + +const updateThemeColor = (theme: ColorTheme) => { + const meta = document.querySelector( + 'meta[name="theme-color"]' + ); + + if (meta) { + meta.content = themeColor[theme]; + } +}; + +const applyColorTheme = (theme: ColorTheme) => { + document.documentElement.dataset.theme = theme; + document.documentElement.style.colorScheme = theme; + + try { + localStorage.setItem(starlightThemeStorageKey, theme); + } catch { + // Persisting the preference is best-effort. + } + + syncStarlightThemeSelects(theme); + syncDocsThemeToggles(theme); + updateThemeColor(theme); + window.StarlightThemeProvider?.updatePickers(theme); +}; + +const syncChartThemeControls = () => { + const theme = getCurrentChartThemePreset(); + + document + .querySelectorAll(selectSelector) + .forEach((select) => { + if (select.value !== theme) { + select.value = theme; + } + }); +}; + +const createChartThemeControl = () => { + const label = document.createElement("label"); + label.className = "chartkit-chart-theme-control"; + label.dataset.chartThemeControl = "true"; + + const text = document.createElement("span"); + text.className = "chartkit-chart-theme-control__label"; + text.textContent = "Chart theme"; + + const select = document.createElement("select"); + select.className = "chartkit-chart-theme-control__select"; + select.dataset.chartThemeSelect = "true"; + select.setAttribute("aria-label", "Chart theme"); + + chartThemeOptions.forEach((option) => { + const item = document.createElement("option"); + item.value = option.value; + item.textContent = option.label; + select.append(item); + }); + + label.append(text, select); + + return label; +}; + +const createDocsThemeToggle = () => { + const button = document.createElement("button"); + button.className = "chartkit-docs-theme-toggle"; + button.dataset.docsThemeToggle = "true"; + button.type = "button"; + button.innerHTML = ` + + `; + + return button; +}; + +const enhanceThemeSelectors = () => { + document + .querySelectorAll("starlight-theme-select") + .forEach((themeSelect) => { + const previousElement = themeSelect.previousElementSibling; + + if (!previousElement?.matches(controlSelector)) { + themeSelect.before(createChartThemeControl()); + } + + if (themeSelect.dataset.chartkitIconToggle !== "true") { + themeSelect.dataset.chartkitIconToggle = "true"; + themeSelect.append(createDocsThemeToggle()); + } + }); + + syncChartThemeControls(); + syncDocsThemeToggles(getCurrentColorTheme()); +}; + +const handleChartThemeChange = (event: Event) => { + const select = (event.target as Element | null)?.closest( + selectSelector + ); + + if (!select || !isChartThemePreset(select.value)) { + return; + } + + applyChartThemePreset(select.value); + syncChartThemeControls(); +}; + +const handleDocsThemeClick = (event: Event) => { + const button = (event.target as Element | null)?.closest( + docsThemeToggleSelector + ); + + if (!button) { + return; + } + + const nextTheme = getCurrentColorTheme() === "dark" ? "light" : "dark"; + applyColorTheme(nextTheme); +}; + +const bootChartThemeControls = () => { + enhanceThemeSelectors(); + applyChartThemePreset(getCurrentChartThemePreset(), { dispatch: false }); + applyColorTheme(getCurrentColorTheme()); + + if (!window.__chartkitChartThemeControlBound) { + window.__chartkitChartThemeControlBound = true; + document.addEventListener("change", handleChartThemeChange); + document.addEventListener("click", handleDocsThemeClick); + } +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bootChartThemeControls, { + once: true + }); +} else { + bootChartThemeControls(); +} + +customElements.whenDefined("starlight-theme-select").then(() => { + bootChartThemeControls(); +}); + +document.addEventListener("astro:page-load", bootChartThemeControls); + +declare global { + interface Window { + StarlightThemeProvider?: { + updatePickers: (theme?: string) => void; + }; + __chartkitChartThemeControlBound?: boolean; + } +} + +export {}; diff --git a/apps/site/src/components/ChartKitFeatures.astro b/apps/site/src/components/ChartKitFeatures.astro index cf166068..5afb6e86 100644 --- a/apps/site/src/components/ChartKitFeatures.astro +++ b/apps/site/src/components/ChartKitFeatures.astro @@ -55,7 +55,7 @@ const features = [ interactive.

Read docs diff --git a/apps/site/src/components/ChartsSupported.tsx b/apps/site/src/components/ChartsSupported.tsx index ebcf19fd..9774d55f 100644 --- a/apps/site/src/components/ChartsSupported.tsx +++ b/apps/site/src/components/ChartsSupported.tsx @@ -1,115 +1,79 @@ import { useEffect, useRef, useState } from "react"; -import type { CSSProperties, ReactNode } from "react"; - -type ChartKind = - | "line" - | "area" - | "bar" - | "stackedBar" - | "pie" - | "donut" - | "progress" - | "heatmap" - | "more"; +import type { CSSProperties } from "react"; + +import { + ChartIllustration, + clamp, + easeOutCubic, + easeOutQuart, + lerp +} from "./ChartsSupportedArtwork"; +import type { ChartKind, ThemeMode } from "./ChartsSupportedArtwork"; type ChartType = { + docsHref?: string; kind: ChartKind; + pro?: boolean; title: string; subtitle?: string; }; -type ThemeMode = "dark" | "light"; +const docsBaseHref = "/docs/react-native"; const chartTypes: ChartType[] = [ - { kind: "line", title: "Line Chart" }, - { kind: "area", title: "Area Chart" }, - { kind: "bar", title: "Bar Chart" }, - { kind: "stackedBar", title: "Stacked Bar Chart" }, - { kind: "pie", title: "Pie Chart" }, - { kind: "donut", title: "Donut Chart" }, - { kind: "progress", title: "Progress Circle" }, - { kind: "heatmap", title: "Contribution Heatmap" }, + { + docsHref: `${docsBaseHref}/charts/line/`, + kind: "line", + title: "Line Chart" + }, + { + docsHref: `${docsBaseHref}/charts/area/`, + kind: "area", + title: "Area Chart" + }, + { docsHref: `${docsBaseHref}/charts/bar/`, kind: "bar", title: "Bar Chart" }, + { + docsHref: `${docsBaseHref}/charts/bar/#stacked-bars`, + kind: "stackedBar", + title: "Stacked Bar Chart" + }, + { docsHref: `${docsBaseHref}/charts/pie/`, kind: "pie", title: "Pie Chart" }, + { + docsHref: `${docsBaseHref}/charts/donut/`, + kind: "donut", + title: "Donut Chart" + }, + { + docsHref: `${docsBaseHref}/charts/progress/`, + kind: "progress", + title: "Progress Circle" + }, + { + docsHref: `${docsBaseHref}/charts/contribution-heatmap/`, + kind: "heatmap", + title: "Contribution Heatmap" + }, + { + docsHref: `${docsBaseHref}/charts/radar/`, + kind: "radar", + pro: true, + title: "Radar Chart" + }, + { + docsHref: `${docsBaseHref}/charts/combo/`, + kind: "combined", + pro: true, + title: "Combo Chart" + }, + { + docsHref: `${docsBaseHref}/charts/candlebar/`, + kind: "candlestick", + pro: true, + title: "Candlebar Chart" + }, { kind: "more", title: "More charts", subtitle: "coming soon" } ]; -const barsA = [44, 58, 76, 64, 92, 118]; -const barsB = [58, 46, 94, 74, 108, 86]; -const stackedA = [ - [38, 28, 18], - [48, 34, 24], - [30, 42, 26], - [64, 30, 20], - [42, 50, 24] -]; -const stackedB = [ - [46, 23, 24], - [40, 42, 18], - [34, 34, 34], - [56, 38, 24], - [50, 42, 30] -]; -const heatmapHot = new Set([2, 6, 10, 13, 17, 24, 26, 31, 37, 41]); -const heatmapWarm = new Set([4, 8, 15, 20, 27, 33, 38, 44]); -const heatmapActive = new Set([1, 7, 11, 18, 22, 29, 34, 40, 43]); - -const clamp = (value: number) => Math.min(Math.max(value, 0), 1); -const lerp = (from: number, to: number, progress: number) => - from + (to - from) * progress; -const easeOutCubic = (value: number) => 1 - Math.pow(1 - value, 3); -const easeOutQuart = (value: number) => 1 - Math.pow(1 - value, 4); -const numberPattern = /-?\d*\.?\d+/g; - -const formatSvgNumber = (value: number) => Number(value.toFixed(2)).toString(); - -const interpolatePath = (base: string, hover: string, progress: number) => { - const baseNumbers = base.match(numberPattern)?.map(Number) ?? []; - const hoverNumbers = hover.match(numberPattern)?.map(Number) ?? []; - const segments = base.split(numberPattern); - - if (baseNumbers.length !== hoverNumbers.length) { - return progress > 0.5 ? hover : base; - } - - return segments - .map((segment, index) => { - if (index >= baseNumbers.length) { - return segment; - } - - return `${segment}${formatSvgNumber( - lerp(baseNumbers[index], hoverNumbers[index], progress) - )}`; - }) - .join(""); -}; - -const staggerProgress = (progress: number, index: number, step = 0.055) => { - const delay = index * step; - return easeOutCubic(clamp((progress - delay) / Math.max(1 - delay, 0.001))); -}; - -const polarPoint = (angle: number, radius = 48) => { - const radians = ((angle - 90) * Math.PI) / 180; - - return { - x: 120 + radius * Math.cos(radians), - y: 75 + radius * Math.sin(radians) - }; -}; - -const pieSlicePath = (startAngle: number, endAngle: number) => { - const start = polarPoint(startAngle); - const end = polarPoint(endAngle); - const largeArc = endAngle - startAngle > 180 ? 1 : 0; - - return [ - "M120 75", - `L${formatSvgNumber(start.x)} ${formatSvgNumber(start.y)}`, - `A48 48 0 ${largeArc} 1 ${formatSvgNumber(end.x)} ${formatSvgNumber(end.y)}`, - "Z" - ].join(""); -}; - const getThemeMode = (): ThemeMode => typeof document !== "undefined" && document.documentElement.dataset.theme === "light" @@ -167,6 +131,15 @@ const getThemeStyles = (mode: ThemeMode) => { label: { color: isLight ? "rgba(0, 0, 0, 0.68)" : "rgba(255, 255, 255, 0.72)" }, + pro: { + backgroundColor: isLight + ? "rgba(0, 0, 0, 0.045)" + : "rgba(255, 255, 255, 0.075)", + borderColor: isLight + ? "rgba(0, 0, 0, 0.12)" + : "rgba(255, 255, 255, 0.14)", + color: isLight ? "rgba(0, 0, 0, 0.62)" : "rgba(255, 255, 255, 0.76)" + }, separator: { background: isLight ? "linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.09) 20%, rgba(0, 0, 0, 0.09) 80%, transparent)" @@ -176,11 +149,6 @@ const getThemeStyles = (mode: ThemeMode) => { background: isLight ? "linear-gradient(to right, transparent, rgba(0, 0, 0, 0.09) 16%, rgba(0, 0, 0, 0.09) 84%, transparent)" : "linear-gradient(to right, transparent, rgba(255, 255, 255, 0.11) 16%, rgba(255, 255, 255, 0.11) 84%, transparent)" - }, - svg: { - filter: isLight - ? "drop-shadow(0 16px 24px rgba(0, 0, 0, 0.08))" - : "drop-shadow(0 16px 24px rgba(255, 255, 255, 0.10))" } } satisfies Record; }; @@ -239,442 +207,6 @@ const useHoverProgress = ( return reducedMotion ? target : progress; }; -const SvgFrame = ({ - children, - label, - mode -}: { - children: ReactNode; - label: string; - mode: ThemeMode; -}) => ( - - {children} - -); - -const MorphPath = ({ - base, - fill, - hover, - opacity = 1, - progress, - stroke, - strokeLinecap, - strokeLinejoin, - strokeWidth -}: { - base: string; - fill: string; - hover: string; - opacity?: number; - progress: number; - stroke?: string; - strokeLinecap?: "round"; - strokeLinejoin?: "round"; - strokeWidth?: string; -}) => ( - -); - -const ChartArtwork = ({ - kind, - mode, - progress, - title -}: Pick & { - mode: ThemeMode; - progress: number; -}) => { - const scopedId = (id: string) => `${id}-${kind}`; - - switch (kind) { - case "line": - return ( - - - - - - - - - - - - - ); - case "area": - return ( - - - - - - - - - - - - ); - case "bar": - return ( - - - - - - - - - - {barsA.map((baseHeight, index) => { - const localProgress = staggerProgress(progress, index); - const height = lerp(baseHeight, barsB[index], localProgress); - - return ( - - ); - })} - - - ); - case "stackedBar": - return ( - - - - - - - - - {stackedA.map((segments, index) => { - const x = 46 + index * 34; - let cursor = 124; - - return segments.map((baseHeight, segmentIndex) => { - const localProgress = staggerProgress( - progress, - index * 2 + segmentIndex, - 0.035 - ); - const height = lerp( - baseHeight, - stackedB[index][segmentIndex], - localProgress - ); - - cursor -= height; - - return ( - - ); - }); - })} - - - ); - case "pie": { - const firstShare = lerp(0.34, 0.39, progress); - const secondShare = lerp(0.38, 0.3, progress); - const firstEnd = firstShare * 360; - const secondEnd = (firstShare + secondShare) * 360; - - return ( - - - - - - - - - - - - - - ); - } - case "donut": - return ( - - - - - - - - ); - case "progress": - return ( - - - - - ); - case "heatmap": - return ( - - - {Array.from({ length: 45 }).map((_, index) => { - const column = index % 9; - const row = Math.floor(index / 9); - const x = 50 + column * 16; - const y = 38 + row * 16; - const opacity = heatmapHot.has(index) - ? 0.82 - : heatmapWarm.has(index) - ? 0.43 - : 0.12; - const activeOpacity = heatmapActive.has(index) - ? 0.92 - : heatmapHot.has(index) - ? 0.36 - : heatmapWarm.has(index) - ? 0.24 - : opacity; - const localProgress = staggerProgress( - progress, - column + row * 1.35, - 0.032 - ); - - return ( - - ); - })} - - - ); - case "more": - return ( - - - - - - - - - - {[ - [72, 75, 84, 75], - [168, 75, 156, 75], - [120, 27, 120, 39], - [120, 123, 120, 111] - ].map(([baseX, baseY, hoverX, hoverY], index) => { - const localProgress = staggerProgress(progress, index, 0.05); - - return ( - - ); - })} - - - ); - } -}; - -const ChartIllustration = ({ - kind, - mode, - progress, - title -}: Pick & { - mode: ThemeMode; - progress: number; -}) => ( -
- -
-); - const ChartTile = ({ chart, index, @@ -701,13 +233,17 @@ const ChartTile = ({ enterDuration, exitDuration ); + const tileClassName = + "group relative block min-w-0 text-current no-underline outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-4px] focus-visible:outline-white/45 [html[data-theme='light']_&]:focus-visible:outline-black/40"; + const tileEvents = { + onBlur: () => setActive(false), + onFocus: () => setActive(true), + onPointerEnter: () => setActive(true), + onPointerLeave: () => setActive(false) + }; - return ( -
setActive(true)} - onPointerLeave={() => setActive(false)} - > + const content = ( + <> {index % 2 === 0 && index < chartTypes.length - 1 && (
+ + ); + + if (!chart.docsHref) { + return ( +
+ {content} +
+ ); + } + + return ( +
+ {content} + ); }; @@ -811,7 +374,7 @@ export default function ChartsSupported() {
Read docs diff --git a/apps/site/src/components/ChartsSupportedArtwork.tsx b/apps/site/src/components/ChartsSupportedArtwork.tsx new file mode 100644 index 00000000..47a1337f --- /dev/null +++ b/apps/site/src/components/ChartsSupportedArtwork.tsx @@ -0,0 +1,701 @@ +import type { ReactNode } from "react"; + +export type ChartKind = + | "line" + | "area" + | "bar" + | "stackedBar" + | "pie" + | "donut" + | "progress" + | "heatmap" + | "radar" + | "combined" + | "candlestick" + | "more"; + +export type ThemeMode = "dark" | "light"; + +type ChartArtworkProps = { + kind: ChartKind; + mode: ThemeMode; + progress: number; + title: string; +}; + +const barsA = [44, 58, 76, 64, 92, 118]; +const barsB = [58, 46, 94, 74, 108, 86]; +const stackedA = [ + [38, 28, 18], + [48, 34, 24], + [30, 42, 26], + [64, 30, 20], + [42, 50, 24] +]; +const stackedB = [ + [46, 23, 24], + [40, 42, 18], + [34, 34, 34], + [56, 38, 24], + [50, 42, 30] +]; +const heatmapHot = new Set([2, 6, 10, 13, 17, 24, 26, 31, 37, 41]); +const heatmapWarm = new Set([4, 8, 15, 20, 27, 33, 38, 44]); +const heatmapActive = new Set([1, 7, 11, 18, 22, 29, 34, 40, 43]); + +export const clamp = (value: number) => Math.min(Math.max(value, 0), 1); +export const lerp = (from: number, to: number, progress: number) => + from + (to - from) * progress; +export const easeOutCubic = (value: number) => 1 - Math.pow(1 - value, 3); +export const easeOutQuart = (value: number) => 1 - Math.pow(1 - value, 4); +const numberPattern = /-?\d*\.?\d+/g; + +const formatSvgNumber = (value: number) => Number(value.toFixed(2)).toString(); + +const interpolatePath = (base: string, hover: string, progress: number) => { + const baseNumbers = base.match(numberPattern)?.map(Number) ?? []; + const hoverNumbers = hover.match(numberPattern)?.map(Number) ?? []; + const segments = base.split(numberPattern); + + if (baseNumbers.length !== hoverNumbers.length) { + return progress > 0.5 ? hover : base; + } + + return segments + .map((segment, index) => { + if (index >= baseNumbers.length) { + return segment; + } + + return `${segment}${formatSvgNumber( + lerp(baseNumbers[index], hoverNumbers[index], progress) + )}`; + }) + .join(""); +}; + +const staggerProgress = (progress: number, index: number, step = 0.055) => { + const delay = index * step; + return easeOutCubic(clamp((progress - delay) / Math.max(1 - delay, 0.001))); +}; + +const polarPoint = (angle: number, radius = 48) => { + const radians = ((angle - 90) * Math.PI) / 180; + + return { + x: 120 + radius * Math.cos(radians), + y: 75 + radius * Math.sin(radians) + }; +}; + +const pieSlicePath = (startAngle: number, endAngle: number) => { + const start = polarPoint(startAngle); + const end = polarPoint(endAngle); + const largeArc = endAngle - startAngle > 180 ? 1 : 0; + + return [ + "M120 75", + `L${formatSvgNumber(start.x)} ${formatSvgNumber(start.y)}`, + `A48 48 0 ${largeArc} 1 ${formatSvgNumber(end.x)} ${formatSvgNumber(end.y)}`, + "Z" + ].join(""); +}; + +const getArtworkFilter = (mode: ThemeMode) => + mode === "light" + ? "drop-shadow(0 16px 24px rgba(0, 0, 0, 0.08))" + : "drop-shadow(0 16px 24px rgba(255, 255, 255, 0.10))"; + +const SvgFrame = ({ + children, + label, + mode +}: { + children: ReactNode; + label: string; + mode: ThemeMode; +}) => ( + + {children} + +); + +const MorphPath = ({ + base, + fill, + hover, + opacity = 1, + progress, + stroke, + strokeLinecap, + strokeLinejoin, + strokeWidth +}: { + base: string; + fill: string; + hover: string; + opacity?: number; + progress: number; + stroke?: string; + strokeLinecap?: "round"; + strokeLinejoin?: "round"; + strokeWidth?: string; +}) => ( + +); + +const ChartArtwork = ({ kind, mode, progress, title }: ChartArtworkProps) => { + const scopedId = (id: string) => `${id}-${kind}`; + + switch (kind) { + case "line": + return ( + + + + + + + + + + + + + ); + case "area": + return ( + + + + + + + + + + + + ); + case "bar": + return ( + + + + + + + + + + {barsA.map((baseHeight, index) => { + const localProgress = staggerProgress(progress, index); + const height = lerp(baseHeight, barsB[index], localProgress); + + return ( + + ); + })} + + + ); + case "stackedBar": + return ( + + + + + + + + + {stackedA.map((segments, index) => { + const x = 46 + index * 34; + let cursor = 124; + + return segments.map((baseHeight, segmentIndex) => { + const localProgress = staggerProgress( + progress, + index * 2 + segmentIndex, + 0.035 + ); + const height = lerp( + baseHeight, + stackedB[index][segmentIndex], + localProgress + ); + + cursor -= height; + + return ( + + ); + }); + })} + + + ); + case "pie": { + const firstShare = lerp(0.34, 0.39, progress); + const secondShare = lerp(0.38, 0.3, progress); + const firstEnd = firstShare * 360; + const secondEnd = (firstShare + secondShare) * 360; + + return ( + + + + + + + + + + + + + + ); + } + case "donut": + return ( + + + + + + + + ); + case "progress": + return ( + + + + + ); + case "heatmap": + return ( + + + {Array.from({ length: 45 }).map((_, index) => { + const column = index % 9; + const row = Math.floor(index / 9); + const x = 50 + column * 16; + const y = 38 + row * 16; + const opacity = heatmapHot.has(index) + ? 0.82 + : heatmapWarm.has(index) + ? 0.43 + : 0.12; + const activeOpacity = heatmapActive.has(index) + ? 0.92 + : heatmapHot.has(index) + ? 0.36 + : heatmapWarm.has(index) + ? 0.24 + : opacity; + const localProgress = staggerProgress( + progress, + column + row * 1.35, + 0.032 + ); + + return ( + + ); + })} + + + ); + case "radar": + return ( + + + + + + + + + + + + + {[ + [120, 39, 120, 32], + [153, 66, 160, 72], + [139, 106, 130, 111], + [98, 94, 96, 86], + [88, 62, 82, 67] + ].map(([baseX, baseY, hoverX, hoverY], index) => { + const localProgress = staggerProgress(progress, index, 0.04); + + return ( + + ); + })} + + ); + case "combined": + return ( + + + + + + + + + {[46, 72, 54, 86, 68].map((baseHeight, index) => { + const localProgress = staggerProgress(progress, index, 0.045); + const height = lerp( + baseHeight, + [68, 52, 74, 62, 94][index], + localProgress + ); + + return ( + + ); + })} + + + + + ); + case "candlestick": + return ( + + + {[ + [52, 48, 116, 60, 120], + [82, 36, 104, 58, 118], + [112, 56, 126, 42, 116], + [142, 42, 108, 50, 114], + [172, 60, 118, 48, 108], + [202, 34, 94, 62, 112] + ].map(([x, y1, y2, activeY1, activeY2], index) => { + const localProgress = staggerProgress(progress, index, 0.035); + + return ( + + ); + })} + + + {[ + [43, 72, 26, 82, 24, 0.34, 0.26], + [73, 52, 38, 64, 30, 0.9, 0.74], + [103, 82, 28, 60, 40, 0.34, 0.42], + [133, 58, 32, 42, 48, 0.9, 0.95], + [163, 82, 24, 50, 44, 0.34, 0.48], + [193, 48, 30, 70, 28, 0.9, 0.62] + ].map( + ( + [x, y, height, activeY, activeHeight, opacity, activeOpacity], + index + ) => { + const localProgress = staggerProgress(progress, index, 0.04); + + return ( + + ); + } + )} + + + ); + case "more": + return ( + + + + + + + + + + {[ + [72, 75, 84, 75], + [168, 75, 156, 75], + [120, 27, 120, 39], + [120, 123, 120, 111] + ].map(([baseX, baseY, hoverX, hoverY], index) => { + const localProgress = staggerProgress(progress, index, 0.05); + + return ( + + ); + })} + + + ); + } +}; + +export const ChartIllustration = ({ + kind, + mode, + progress, + title +}: ChartArtworkProps) => ( +
+ +
+); diff --git a/apps/site/src/components/Footer.astro b/apps/site/src/components/Footer.astro index 8573e498..585bacb8 100644 --- a/apps/site/src/components/Footer.astro +++ b/apps/site/src/components/Footer.astro @@ -18,7 +18,7 @@ const footerGroups = [ label: "Pricing" }, { - href: "/docs", + href: "/docs/react-native", label: "Docs" } ] @@ -27,15 +27,15 @@ const footerGroups = [ title: "Company", links: [ { - href: "/docs/migration/from-v1", + href: "/docs/react-native/migration/from-v1", label: "Migration" }, { - href: "/docs/recipes", + href: "/docs/react-native/recipes", label: "Recipes" }, { - href: "/docs/troubleshooting", + href: "/docs/react-native/troubleshooting", label: "Support" } ] diff --git a/apps/site/src/components/GoogleAnalytics.astro b/apps/site/src/components/GoogleAnalytics.astro new file mode 100644 index 00000000..aa1f9d4c --- /dev/null +++ b/apps/site/src/components/GoogleAnalytics.astro @@ -0,0 +1,15 @@ + + + diff --git a/apps/site/src/components/Head.astro b/apps/site/src/components/Head.astro index eab32df5..1defa617 100644 --- a/apps/site/src/components/Head.astro +++ b/apps/site/src/components/Head.astro @@ -1,4 +1,7 @@ --- +import GoogleAnalytics from "./GoogleAnalytics.astro"; +import ThemeInit from "./ThemeInit.astro"; + const { head } = Astro.locals.starlightRoute; const isDev = import.meta.env.DEV; const reactRefreshPreamble = ` @@ -10,12 +13,89 @@ const reactRefreshPreamble = ` `; --- -{head.map(({ tag: Tag, attrs, content }) => )} + + + +{ + head.map(({ tag: Tag, attrs, content }) => ( + + )) +} { isDev ? ( + diff --git a/apps/site/src/components/Header.astro b/apps/site/src/components/Header.astro index 0cd9c9ab..b9a1a1f7 100644 --- a/apps/site/src/components/Header.astro +++ b/apps/site/src/components/Header.astro @@ -15,7 +15,7 @@ const navigationLinks = [ label: "Pricing" }, { - href: "/docs", + href: "/docs/react-native", label: "Docs" } ]; diff --git a/apps/site/src/components/Pricing.astro b/apps/site/src/components/Pricing.astro index a9b00d18..2a4b3a89 100644 --- a/apps/site/src/components/Pricing.astro +++ b/apps/site/src/components/Pricing.astro @@ -11,7 +11,7 @@ const plans = [ cadence: "", description: "For hobby apps and simple dashboards.", cta: "Get started", - href: "/docs/getting-started/installation", + href: "/docs/react-native/getting-started/installation", featured: false, features: [ "Basic charts", @@ -27,7 +27,7 @@ const plans = [ cadence: "/ dev / year", description: "For production apps.", cta: "Start Pro", - href: "/docs/getting-started/installation", + href: "/docs/react-native/getting-started/installation", featured: true, features: [ "Smart layout engine", diff --git a/apps/site/src/components/ThemeInit.astro b/apps/site/src/components/ThemeInit.astro index cc64463f..1912a4ad 100644 --- a/apps/site/src/components/ThemeInit.astro +++ b/apps/site/src/components/ThemeInit.astro @@ -1,6 +1,19 @@ diff --git a/apps/site/src/content.config.ts b/apps/site/src/content.config.ts index 4511ce00..8e352a02 100644 --- a/apps/site/src/content.config.ts +++ b/apps/site/src/content.config.ts @@ -10,17 +10,18 @@ const publicDocs = [ "recipes/**/*.md", "troubleshooting.md" ]; +const docsRoutePrefix = "docs/react-native"; const getRouteId = (entry: string) => { if (entry === "README.md") { - return "docs"; + return docsRoutePrefix; } if (entry.endsWith("/README.md")) { - return `docs/${entry.slice(0, -"/README.md".length)}`; + return `${docsRoutePrefix}/${entry.slice(0, -"/README.md".length)}`; } - return `docs/${entry.replace(/\.mdx?$/, "")}`; + return `${docsRoutePrefix}/${entry.replace(/\.mdx?$/, "")}`; }; export const collections = { diff --git a/apps/site/src/lib/remark-strip-duplicate-title.mjs b/apps/site/src/lib/remark-strip-duplicate-title.mjs index 8e620aa1..85afa009 100644 --- a/apps/site/src/lib/remark-strip-duplicate-title.mjs +++ b/apps/site/src/lib/remark-strip-duplicate-title.mjs @@ -40,16 +40,18 @@ const getDocsEntryPath = (file) => { return undefined; }; +const docsRoutePrefix = "/docs/react-native"; + const getDocsRoute = (docsPath) => { if (docsPath === "README.md") { - return "/docs/"; + return `${docsRoutePrefix}/`; } if (docsPath.endsWith("/README.md")) { - return `/docs/${docsPath.slice(0, -"/README.md".length)}/`; + return `${docsRoutePrefix}/${docsPath.slice(0, -"/README.md".length)}/`; } - return `/docs/${docsPath.replace(/\.mdx?$/, "")}/`; + return `${docsRoutePrefix}/${docsPath.replace(/\.mdx?$/, "")}/`; }; const rewriteMarkdownLinks = (tree, file) => { @@ -98,7 +100,150 @@ const escapeAttribute = (value) => .replace(/"/g, """) .replace(/ { +const escapeHtml = (value) => + String(value) + .replace(/&/g, "&") + .replace(//g, ">"); + +const encodeCodeAttribute = (value) => encodeURIComponent(String(value)); + +const playgroundDocs = new Set([ + "getting-started/installation.md", + "recipes/README.md", + "charts/area.md", + "charts/bar.md", + "charts/contribution-heatmap.md", + "charts/candlebar.md", + "charts/combo.md", + "charts/donut.md", + "charts/line.md", + "charts/pie.md", + "charts/progress.md", + "charts/radar.md" +]); + +const chartComponentPattern = + /<\s*(AreaChart|BarChart|CandlebarChart|CandlestickChart|ComboChart|ContributionGraph|DonutChart|LineChart|PieChart|ProgressChart|ProgressRing|RadarChart|StackedBarChart)\b/; + +const slugify = (value) => + String(value) + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + +const getPreviewHtml = (id, title) => { + const titleAttribute = + typeof title === "string" && title.length > 0 + ? ` data-preview-title="${escapeAttribute(title)}"` + : ""; + + return `
Loading chart preview
`; +}; + +const getPlaygroundHtml = (id, code, title) => { + const titleAttribute = + typeof title === "string" && title.length > 0 + ? ` data-preview-title="${escapeAttribute(title)}"` + : ""; + + return `
${escapeHtml(
+    code
+  )}
`; +}; + +const isRenderableChartExample = (node, docsPath) => + playgroundDocs.has(docsPath) && + node?.type === "code" && + ["jsx", "tsx"].includes(node.lang) && + chartComponentPattern.test(node.value ?? ""); + +const getGeneratedPreviewId = (docsPath, title, index) => { + const pathSlug = slugify( + docsPath.replace(/\/README\.md$/, "").replace(/\.mdx?$/, "") + ); + const titleSlug = slugify(title || "example"); + + return `${pathSlug}-${titleSlug || "example"}-${index}`; +}; + +const transformPreviewDirectives = (tree, file) => { + const docsPath = getDocsEntryPath(file); + + if (Array.isArray(tree.children)) { + let currentHeading = file.data?.astro?.frontmatter?.title ?? ""; + let generatedPreviewIndex = 0; + + for (let index = 0; index < tree.children.length; index += 1) { + const node = tree.children[index]; + + if (node.type === "heading" && node.depth >= 2) { + currentHeading = textFromNode(node).trim(); + continue; + } + + if ( + isRenderableChartExample(node, docsPath) && + tree.children[index + 1]?.type !== "leafDirective" + ) { + generatedPreviewIndex += 1; + node.type = "html"; + node.value = getPlaygroundHtml( + getGeneratedPreviewId( + docsPath, + currentHeading, + generatedPreviewIndex + ), + node.value, + currentHeading + ); + node.children = []; + continue; + } + + if (node.type !== "leafDirective" || node.name !== "chart-preview") { + continue; + } + + const id = node.attributes?.id; + + if (typeof id !== "string" || id.length === 0) { + node.type = "html"; + node.value = + '
Missing chart preview id
'; + node.children = []; + continue; + } + + const previousNode = tree.children[index - 1]; + + if ( + previousNode?.type === "code" && + ["jsx", "tsx"].includes(previousNode.lang) + ) { + const title = node.attributes?.title; + + previousNode.type = "html"; + previousNode.value = getPlaygroundHtml(id, previousNode.value, title); + previousNode.children = []; + tree.children.splice(index, 1); + index -= 1; + continue; + } + + node.type = "html"; + node.value = getPreviewHtml(id, node.attributes?.title); + node.children = []; + } + } + visit(tree, (node) => { if (node.type !== "leafDirective" || node.name !== "chart-preview") { return; @@ -114,16 +259,8 @@ const transformPreviewDirectives = (tree) => { return; } - const title = node.attributes?.title; - const titleAttribute = - typeof title === "string" && title.length > 0 - ? ` data-preview-title="${escapeAttribute(title)}"` - : ""; - node.type = "html"; - node.value = `
Loading chart preview
`; + node.value = getPreviewHtml(id, node.attributes?.title); node.children = []; }); }; @@ -167,6 +304,6 @@ export default function stripDuplicateTitle() { }); rewriteMarkdownLinks(tree, file); - transformPreviewDirectives(tree); + transformPreviewDirectives(tree, file); }; } diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro index 78b12751..2cfa1b7c 100644 --- a/apps/site/src/pages/index.astro +++ b/apps/site/src/pages/index.astro @@ -4,6 +4,7 @@ import ChartKitFeatures from "../components/ChartKitFeatures.astro"; import ChartKitStats from "../components/ChartKitStats.astro"; import ChartsSupported from "../components/ChartsSupported"; import Footer from "../components/Footer.astro"; +import GoogleAnalytics from "../components/GoogleAnalytics.astro"; import Header from "../components/Header.astro"; import Hero from "../components/Hero.astro"; import Pricing from "../components/Pricing.astro"; @@ -17,6 +18,7 @@ import ThemeInit from "../components/ThemeInit.astro"; React Native Chart Kit +
diff --git a/apps/site/src/previews/ChartPlayground.tsx b/apps/site/src/previews/ChartPlayground.tsx new file mode 100644 index 00000000..057f4f35 --- /dev/null +++ b/apps/site/src/previews/ChartPlayground.tsx @@ -0,0 +1,695 @@ +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState +} from "react"; +import { Text, View } from "react-native"; +import { + LiveContext, + LiveEditor, + LiveError, + LivePreview, + LiveProvider +} from "react-live"; + +import { + AreaChart, + BarChart, + ChartKitProvider, + ContributionGraph, + createChartPreset, + DonutChart, + LineChart, + PieChart, + ProgressChart, + ProgressRing, + StackedBarChart, + type ChartKitThemeMode +} from "react-native-chart-kit/v2"; +import { CandlebarChart, ComboChart, RadarChart } from "@chart-kit/pro"; +import { G, Line as SvgLine, Rect, Text as SvgText } from "react-native-svg"; + +import { + chartThemeChangeEvent, + getCurrentChartThemePreset, + type ChartThemePreset +} from "./chartTheme"; +import { + acquisitionShare, + candlebarPrices, + clampChartWidth, + comboRevenue, + contributionEndDate, + contributionNumDays, + contributionValues, + money, + monthRevenue, + percent, + platformShare, + profit, + progressRings, + radarBenchmarks, + revenueMix, + signedMoney, + signups, + supportVolume +} from "./data"; +import { chartPreviewExamples } from "./registry"; +import { showcaseCustomPresets } from "./showcaseTheme"; + +type LiveEditorTheme = NonNullable< + React.ComponentProps["theme"] +>; + +const importStatementPattern = + /^\s*import(?:[\s\S]*?\sfrom\s+["'][^"']+["']|(?:\s+type)?\s+["'][^"']+["']);?\s*/gm; + +const explicitThemePattern = + /\b(?:ChartKitProvider|createChartPreset)\b|\b(?:preset|theme)\s*=/; + +const explicitPalettePattern = /\b(?:colorKey|colors)\s*=|\bcolor\s*:/; + +const chartKitLightEditorTheme: LiveEditorTheme = { + plain: { + backgroundColor: "transparent", + color: "#071733" + }, + styles: [ + { + types: ["comment", "prolog", "cdata"], + style: { color: "#64748b", fontStyle: "italic" } + }, + { + types: ["punctuation", "operator"], + style: { color: "#334155" } + }, + { + types: ["keyword", "boolean", "constant"], + style: { color: "#7e22ce", fontWeight: "700" } + }, + { + types: ["string", "char", "attr-value", "regex"], + style: { color: "#047857" } + }, + { + types: ["number", "builtin", "class-name"], + style: { color: "#b45309" } + }, + { + types: ["function", "method"], + style: { color: "#1d4ed8" } + }, + { + types: ["tag", "selector", "property", "symbol"], + style: { color: "#be123c" } + }, + { + types: ["attr-name", "variable"], + style: { color: "#0f766e" } + } + ] +}; + +const getThemeMode = (): Exclude => + document.documentElement.dataset.theme === "light" ? "light" : "dark"; + +const getComponentName = (code: string) => { + const declaration = + code.match(/\bexport\s+default\s+function\s+([A-Z][\w]*)/) ?? + code.match(/\bexport\s+function\s+([A-Z][\w]*)/) ?? + code.match(/\bfunction\s+([A-Z][\w]*)/) ?? + code.match(/\bexport\s+const\s+([A-Z][\w]*)\s*=/) ?? + code.match(/\bconst\s+([A-Z][\w]*)\s*=/); + + return declaration?.[1]; +}; + +const getStatementStyleLiveCode = (code: string) => { + const lines = code.split("\n"); + const firstJsxLineIndex = lines.findIndex((line) => + /^\s*<[A-Z][\w.]*/.test(line) + ); + + if (firstJsxLineIndex === -1) { + return undefined; + } + + const setupCode = lines.slice(0, firstJsxLineIndex).join("\n").trim(); + const jsxCode = lines + .slice(firstJsxLineIndex) + .join("\n") + .trim() + .replace(/(\/>|<\/[A-Z][\w.]*>);/g, "$1"); + + return `function ChartKitLiveExample() { +${setupCode ? `${setupCode}\n\n` : ""}return ( + <> +${jsxCode} + +); +} + +render();`; +}; + +const prepareLiveCode = (code: string) => { + const componentName = getComponentName(code); + const runnableCode = code + .replace(importStatementPattern, "") + .replace(/\bexport\s+default\s+function\s+/g, "function ") + .replace(/\bexport\s+function\s+/g, "function ") + .replace(/\bexport\s+const\s+/g, "const ") + .replace(/\bexport\s+default\s+/g, "") + .trim(); + + if (/render\s*\(/.test(runnableCode)) { + return `(() => {\n${runnableCode}\n})();`; + } + + if (componentName) { + return `(() => {\n${runnableCode}\n\nrender(<${componentName} />);\n})();`; + } + + const statementStyleLiveCode = getStatementStyleLiveCode(runnableCode); + + if (statementStyleLiveCode) { + return `(() => {\n${statementStyleLiveCode}\n})();`; + } + + return `(() => {\nrender(${runnableCode});\n})();`; +}; + +const decodeInitialCode = (value: string) => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +const DEFAULT_EDITOR_SIZE = 50; +const MIN_EDITOR_SIZE = 30; +const MAX_EDITOR_SIZE = 70; + +const clamp = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)); + +const barPlaygroundData = signups.map((row) => ({ + ...row, + newCustomers: row.signups, + organic: row.organic, + paid: row.paid, + spend: row.signups, + week: row.month +})); + +const linePlaygroundData = monthRevenue.map((row, index) => { + const barRow = barPlaygroundData[index % barPlaygroundData.length]; + + return { + ...row, + actual: row.revenue, + attainment: row.retention, + benchmark: row.forecast, + date: row.month, + expansion: barRow.expansion, + newCustomers: barRow.newCustomers, + organic: barRow.organic, + paid: barRow.paid, + portfolio: row.revenue, + price: row.revenue, + signups: barRow.signups, + spend: barRow.spend, + timestamp: index + 1, + week: barRow.week + }; +}); + +const revenueMixPlaygroundData = revenueMix.map((row) => ({ + ...row, + plan: row.label, + revenue: row.value +})); + +const acquisitionSharePlaygroundData = acquisitionShare.map((row) => ({ + ...row, + share: row.value +})); + +const weeklySpend = [ + 18, 52, 26, 74, 31, 88, 43, 96, 39, 108, 57, 121, 44, 132 +].map((spend, index) => ({ + spend, + week: `W${index + 1}` +})); + +const weeklyAcquisition = weeklySpend.map((row, index) => ({ + ...row, + organic: [28, 74, 39, 96, 54, 118, 63, 132, 71, 148, 84, 156, 92, 171][ + index + ]!, + paid: [62, 34, 88, 41, 103, 58, 117, 49, 126, 66, 139, 73, 144, 81][index]! +})); + +const portfolioHistory = Array.from({ length: 120 }, (_, index) => { + const portfolio = 62000 + index * 780 + Math.sin(index / 2.1) * 15000; + const benchmark = 98000 - index * 170 + Math.cos(index / 2.8) * 12000; + + return { + benchmark, + date: `Day ${index + 1}`, + month: `Day ${index + 1}`, + portfolio, + price: portfolio, + timestamp: index + 1 + }; +}); + +const largeData = portfolioHistory.map((row, index) => ({ + ...row, + price: + 120 + index * 1.2 + Math.sin(index / 1.7) * 38 + Math.cos(index / 5) * 22 +})); + +const retentionSegments = [ + { accounts: 124, color: "#00163f", status: "Active" }, + { accounts: 46, color: "#2f5f9f", status: "At risk" }, + { accounts: 18, color: "#6f88aa", status: "Paused" } +]; + +const previewDataById: Record = { + "bar-grouped": barPlaygroundData, + "line-multi-series": linePlaygroundData, + "line-selection": linePlaygroundData, + "pro-candlebar": candlebarPrices, + "pro-combo": comboRevenue, + "pro-radar": radarBenchmarks +}; + +const getPreviewData = (id: string) => + previewDataById[id] ?? linePlaygroundData; + +const writeClipboard = async (value: string) => { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); +}; + +const TypeScriptLogo = () => ( + +); + +const CopyIcon = () => ( + +); + +const CheckIcon = () => ( + +); + +const CodePaneHeader = ({ codeToCopy }: { codeToCopy: string }) => { + const [copied, setCopied] = useState(false); + const copiedResetRef = useRef(undefined); + + useEffect( + () => () => { + if (copiedResetRef.current) { + window.clearTimeout(copiedResetRef.current); + } + }, + [] + ); + + const copyCode = useCallback(async () => { + await writeClipboard(codeToCopy).catch(() => undefined); + setCopied(true); + + if (copiedResetRef.current) { + window.clearTimeout(copiedResetRef.current); + } + + copiedResetRef.current = window.setTimeout(() => { + setCopied(false); + }, 1800); + }, [codeToCopy]); + + return ( +
+ + + TypeScript + + +
+ ); +}; + +const LiveCodeEditor = ({ + onCodeChange +}: { + onCodeChange: (value: string) => void; +}) => { + const { onChange } = useContext(LiveContext); + + return ( + { + onCodeChange(value); + onChange(value); + }} + tabMode="indentation" + /> + ); +}; + +export const ChartPlayground = ({ code, id }: { code: string; id: string }) => { + const frameRef = useRef(null); + const gridRef = useRef(null); + const previewPaneRef = useRef(null); + const [chartThemePreset, setChartThemePreset] = useState( + () => getCurrentChartThemePreset() + ); + const [mode, setMode] = useState>(() => + getThemeMode() + ); + const [editorSize, setEditorSize] = useState(DEFAULT_EDITOR_SIZE); + const [isResizing, setIsResizing] = useState(false); + const [width, setWidth] = useState(420); + const initialCode = useMemo(() => decodeInitialCode(code), [code]); + const [currentCode, setCurrentCode] = useState(() => initialCode); + const example = chartPreviewExamples[id]; + const supportsGlobalChartTheme = useMemo( + () => + !explicitThemePattern.test(initialCode) && + !explicitPalettePattern.test(initialCode), + [initialCode] + ); + + useEffect(() => { + const previewPane = previewPaneRef.current; + + if (!previewPane) { + return; + } + + const resize = () => { + setWidth(Math.max(280, Math.floor(previewPane.clientWidth - 32))); + }; + const resizeObserver = new ResizeObserver(resize); + resizeObserver.observe(previewPane); + resize(); + + return () => resizeObserver.disconnect(); + }, []); + + useEffect(() => { + const updateMode = () => setMode(getThemeMode()); + const themeObserver = new MutationObserver(updateMode); + themeObserver.observe(document.documentElement, { + attributeFilter: ["data-theme"] + }); + updateMode(); + + return () => themeObserver.disconnect(); + }, []); + + useEffect(() => { + const updateChartThemePreset = () => + setChartThemePreset(getCurrentChartThemePreset()); + const chartThemeObserver = new MutationObserver(updateChartThemePreset); + chartThemeObserver.observe(document.documentElement, { + attributeFilter: ["data-chart-theme"] + }); + + window.addEventListener(chartThemeChangeEvent, updateChartThemePreset); + updateChartThemePreset(); + + return () => { + chartThemeObserver.disconnect(); + window.removeEventListener(chartThemeChangeEvent, updateChartThemePreset); + }; + }, []); + + const scope = useMemo( + () => ({ + AreaChart, + BarChart, + CandlebarChart, + ContributionGraph, + ChartKitProvider, + ComboChart, + DonutChart, + G, + LineChart, + PieChart, + ProgressChart, + ProgressRing, + RadarChart, + React, + Rect, + StackedBarChart, + SvgText, + Text, + View, + acquisitionShare: acquisitionSharePlaygroundData, + candlebarPrices, + clampChartWidth, + comboRevenue, + contributionValues, + contributionEndDate, + contributionNumDays, + chartThemePreset, + createChartPreset, + data: getPreviewData(id), + largeData, + money, + monthRevenue, + percent, + plans: revenueMixPlaygroundData, + platformShare, + portfolioHistory, + previewWidth: clampChartWidth(width), + profit, + progressRings, + radarBenchmarks, + revenueMix: revenueMixPlaygroundData, + retentionSegments, + signedMoney, + signups, + setHeaderValue: () => undefined, + setSelectedChannel: () => undefined, + setSelectedDay: () => undefined, + setViewport: () => undefined, + supportVolume, + SvgLine, + Line: SvgLine, + usageDays: contributionValues, + useState: React.useState, + values: contributionValues, + viewport: { endIndex: 90, startIndex: 40 }, + weeklyAcquisition, + weeklySpend + }), + [chartThemePreset, id, width] + ); + + const playgroundStyle = useMemo( + () => + ({ + "--chart-kit-editor-size": `${editorSize}%` + }) as React.CSSProperties, + [editorSize] + ); + + const resizeFromClientX = useCallback((clientX: number) => { + const grid = gridRef.current; + + if (!grid) { + return; + } + + const rect = grid.getBoundingClientRect(); + const nextSize = ((clientX - rect.left) / rect.width) * 100; + setEditorSize(clamp(nextSize, MIN_EDITOR_SIZE, MAX_EDITOR_SIZE)); + }, []); + + const adjustEditorSize = useCallback((delta: number) => { + setEditorSize((size) => + clamp(size + delta, MIN_EDITOR_SIZE, MAX_EDITOR_SIZE) + ); + }, []); + + const previewPreset = supportsGlobalChartTheme ? chartThemePreset : "default"; + + return ( +
+ +
+
+ + +
+ +
+
+
+ ); +}; diff --git a/apps/site/src/previews/ChartPreview.tsx b/apps/site/src/previews/ChartPreview.tsx index 0b00b3e7..387ffc8a 100644 --- a/apps/site/src/previews/ChartPreview.tsx +++ b/apps/site/src/previews/ChartPreview.tsx @@ -1,5 +1,10 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + chartThemeChangeEvent, + getCurrentChartThemePreset, + type ChartThemePreset +} from "./chartTheme"; import { renderChartPreview } from "./examples"; const getThemeMode = (): "dark" | "light" => @@ -7,6 +12,9 @@ const getThemeMode = (): "dark" | "light" => export const ChartPreview = ({ id }: { id: string }) => { const frameRef = useRef(null); + const [chartThemePreset, setChartThemePreset] = useState( + () => getCurrentChartThemePreset() + ); const [mode, setMode] = useState<"dark" | "light">(() => getThemeMode()); const [width, setWidth] = useState(460); @@ -38,9 +46,26 @@ export const ChartPreview = ({ id }: { id: string }) => { return () => themeObserver.disconnect(); }, []); + useEffect(() => { + const updateChartThemePreset = () => + setChartThemePreset(getCurrentChartThemePreset()); + const chartThemeObserver = new MutationObserver(updateChartThemePreset); + chartThemeObserver.observe(document.documentElement, { + attributeFilter: ["data-chart-theme"] + }); + + window.addEventListener(chartThemeChangeEvent, updateChartThemePreset); + updateChartThemePreset(); + + return () => { + chartThemeObserver.disconnect(); + window.removeEventListener(chartThemeChangeEvent, updateChartThemePreset); + }; + }, []); + const preview = useMemo( - () => renderChartPreview({ id, mode, width }), - [id, mode, width] + () => renderChartPreview({ chartThemePreset, id, mode, width }), + [chartThemePreset, id, mode, width] ); return ( diff --git a/apps/site/src/previews/chartTheme.ts b/apps/site/src/previews/chartTheme.ts new file mode 100644 index 00000000..d6de2e6c --- /dev/null +++ b/apps/site/src/previews/chartTheme.ts @@ -0,0 +1,82 @@ +export type ChartThemePreset = + | "default" + | "analytics" + | "fintech" + | "health" + | "ios" + | "material" + | "minimal" + | "highContrast" + | "darkFintech" + | "studio"; + +export const chartThemeStorageKey = "chartkit-chart-theme"; +export const chartThemeChangeEvent = "chartkit:chart-theme-change"; + +export const chartThemeOptions: Array<{ + label: string; + value: ChartThemePreset; +}> = [ + { label: "Default", value: "default" }, + { label: "Analytics", value: "analytics" }, + { label: "Fintech", value: "fintech" }, + { label: "Health", value: "health" }, + { label: "iOS", value: "ios" }, + { label: "Material", value: "material" }, + { label: "Minimal", value: "minimal" }, + { label: "High contrast", value: "highContrast" }, + { label: "Dark fintech", value: "darkFintech" }, + { label: "Studio", value: "studio" } +]; + +export const isChartThemePreset = ( + value: string | null | undefined +): value is ChartThemePreset => + chartThemeOptions.some((option) => option.value === value); + +export const getCurrentChartThemePreset = (): ChartThemePreset => { + if (typeof document !== "undefined") { + const documentTheme = document.documentElement.dataset.chartTheme; + + if (isChartThemePreset(documentTheme)) { + return documentTheme; + } + } + + if (typeof localStorage !== "undefined") { + try { + const storedTheme = localStorage.getItem(chartThemeStorageKey); + + if (isChartThemePreset(storedTheme)) { + return storedTheme; + } + } catch { + // Storage can be blocked in restricted browser contexts. + } + } + + return "default"; +}; + +export const applyChartThemePreset = ( + preset: ChartThemePreset, + options: { dispatch?: boolean } = {} +) => { + if (typeof document !== "undefined") { + document.documentElement.dataset.chartTheme = preset; + } + + if (typeof localStorage !== "undefined") { + try { + localStorage.setItem(chartThemeStorageKey, preset); + } catch { + // Persisting the preference is best-effort. + } + } + + if (options.dispatch !== false && typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent(chartThemeChangeEvent, { detail: { preset } }) + ); + } +}; diff --git a/apps/site/src/previews/client.tsx b/apps/site/src/previews/client.tsx index 3516d89f..cf8b3477 100644 --- a/apps/site/src/previews/client.tsx +++ b/apps/site/src/previews/client.tsx @@ -1,6 +1,7 @@ import React from "react"; import { createRoot, type Root } from "react-dom/client"; +import { ChartPlayground } from "./ChartPlayground"; import { ChartPreview } from "./ChartPreview"; const roots = new WeakMap(); @@ -17,22 +18,49 @@ const mountPreview = (element: Element) => { root.render(); }; +const mountPlayground = (element: Element) => { + const id = element.getAttribute("data-preview-id"); + const code = element.getAttribute("data-code"); + + if (!id || !code || roots.has(element)) { + return; + } + + const root = createRoot(element); + roots.set(element, root); + root.render(); +}; + class ChartKitPreviewElement extends HTMLElement { connectedCallback() { mountPreview(this); } } +class ChartKitPlaygroundElement extends HTMLElement { + connectedCallback() { + mountPlayground(this); + } +} + const scan = () => { const previews = Array.from(document.querySelectorAll("chart-kit-preview")); + const playgrounds = Array.from( + document.querySelectorAll("chart-kit-playground") + ); previews.forEach(mountPreview); + playgrounds.forEach(mountPlayground); }; if (!customElements.get("chart-kit-preview")) { customElements.define("chart-kit-preview", ChartKitPreviewElement); } +if (!customElements.get("chart-kit-playground")) { + customElements.define("chart-kit-playground", ChartKitPlaygroundElement); +} + if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", scan, { once: true }); } else { diff --git a/apps/site/src/previews/data.ts b/apps/site/src/previews/data.ts index f20ab92a..e73f11db 100644 --- a/apps/site/src/previews/data.ts +++ b/apps/site/src/previews/data.ts @@ -7,72 +7,130 @@ export const clampChartWidth = (width: number, max = 460) => Math.max(280, Math.min(max, Math.round(width))); export const monthRevenue = [ - { month: "Jan", revenue: 42, forecast: 45, retention: 91 }, - { month: "Feb", revenue: 56, forecast: 54, retention: 94 }, - { month: "Mar", revenue: 61, forecast: 63, retention: 96 }, - { month: "Apr", revenue: 75, forecast: 70, retention: 99 }, - { month: "May", revenue: 83, forecast: 82, retention: 101 }, - { month: "Jun", revenue: 96, forecast: 90, retention: 104 } + { month: "Jan", revenue: 52, forecast: 236, retention: 62 }, + { month: "Feb", revenue: 86, forecast: 218, retention: 138 }, + { month: "Mar", revenue: 58, forecast: 201, retention: 74 }, + { month: "Apr", revenue: 134, forecast: 182, retention: 151 }, + { month: "May", revenue: 95, forecast: 164, retention: 89 }, + { month: "Jun", revenue: 176, forecast: 148, retention: 122 }, + { month: "Jul", revenue: 126, forecast: 132, retention: 55 }, + { month: "Aug", revenue: 218, forecast: 116, retention: 164 }, + { month: "Sep", revenue: 164, forecast: 101, retention: 96 }, + { month: "Oct", revenue: 252, forecast: 88, retention: 145 }, + { month: "Nov", revenue: 198, forecast: 73, retention: 78 }, + { month: "Dec", revenue: 286, forecast: 62, retention: 172 } ]; export const signups = [ - { month: "Jan", signups: 320, expansion: 98 }, - { month: "Feb", signups: 410, expansion: 120 }, - { month: "Mar", signups: 365, expansion: 134 }, - { month: "Apr", signups: 520, expansion: 172 }, - { month: "May", signups: 610, expansion: 198 }, - { month: "Jun", signups: 690, expansion: 230 } + { month: "Jan", signups: 180, expansion: 60, organic: 28, paid: 62 }, + { month: "Feb", signups: 520, expansion: 210, organic: 74, paid: 34 }, + { month: "Mar", signups: 260, expansion: 120, organic: 39, paid: 88 }, + { month: "Apr", signups: 740, expansion: 330, organic: 96, paid: 41 }, + { month: "May", signups: 390, expansion: 170, organic: 54, paid: 103 }, + { month: "Jun", signups: 860, expansion: 410, organic: 118, paid: 58 } ]; export const profit = [ - { month: "Jan", profit: -18 }, - { month: "Feb", profit: -6 }, - { month: "Mar", profit: 12 }, - { month: "Apr", profit: 28 }, - { month: "May", profit: 43 }, - { month: "Jun", profit: 35 } + { month: "Jan", profit: 38 }, + { month: "Feb", profit: -28 }, + { month: "Mar", profit: 64 }, + { month: "Apr", profit: -42 }, + { month: "May", profit: 81 }, + { month: "Jun", profit: -18 } ]; export const supportVolume = [ - { channel: "Email", tickets: 42 }, - { channel: "Chat", tickets: 88 }, - { channel: "Voice", tickets: 36 }, - { channel: "Soc", tickets: 62 } + { channel: "Chat", tickets: 95 }, + { channel: "Email", tickets: 37 }, + { channel: "Phone", tickets: 68 }, + { channel: "Social", tickets: 24 }, + { channel: "Community", tickets: 113 } ]; export const platformShare = [ - { month: "Jan", ios: 44, android: 38, web: 18 }, - { month: "Feb", ios: 42, android: 40, web: 18 }, - { month: "Mar", ios: 45, android: 37, web: 18 }, - { month: "Apr", ios: 48, android: 34, web: 18 } + { month: "Jan", ios: 62, android: 25, web: 13 }, + { month: "Feb", ios: 38, android: 47, web: 15 }, + { month: "Mar", ios: 55, android: 21, web: 24 }, + { month: "Apr", ios: 29, android: 58, web: 13 }, + { month: "May", ios: 68, android: 19, web: 13 }, + { month: "Jun", ios: 44, android: 31, web: 25 } ]; export const acquisitionShare = [ - { channel: "Organic", value: 44, color: "#00163f" }, - { channel: "Paid", value: 28, color: "#2f5f9f" }, - { channel: "Referral", value: 18, color: "#6f88aa" }, - { channel: "Partner", value: 10, color: "#46566f" } + { channel: "Organic search", value: 42, color: "#00163f" }, + { channel: "Paid social", value: 24, color: "#2f5f9f" }, + { channel: "Referrals", value: 18, color: "#6f88aa" }, + { channel: "Partners", value: 10, color: "#46566f" }, + { channel: "Lifecycle", value: 6, color: "#9aa8bd" } ]; export const revenueMix = [ - { label: "Core", value: 58, color: "#00163f" }, - { label: "Expansion", value: 27, color: "#2f5f9f" }, - { label: "Services", value: 15, color: "#6f88aa" } + { label: "Enterprise", value: 680, color: "#00163f" }, + { label: "Business", value: 420, color: "#2f5f9f" }, + { label: "Teams", value: 260, color: "#6f88aa" }, + { label: "Starter", value: 140, color: "#9aa8bd" } ]; export const progressRings = [ - { label: "Activation", value: 0.74, color: "#00163f" }, - { label: "Retention", value: 0.62, color: "#2f5f9f" }, - { label: "Expansion", value: 0.48, color: "#6f88aa" } + { label: "Build signed", value: 0.76, color: "#00163f" }, + { label: "QA pass", value: 0, color: "#2f5f9f" }, + { label: "Rollout cap", value: 0.42, color: "#6f88aa" } ]; -export const contributionValues = Array.from({ length: 84 }, (_, index) => { - const date = new Date(Date.UTC(2026, 0, 1 + index)); - const wave = Math.sin(index / 4) + Math.cos(index / 9); - const count = Math.max(0, Math.round((wave + 1.6) * 4)); +export const contributionEndDate = "2026-05-03"; +export const contributionNumDays = 154; - return { - date: date.toISOString().slice(0, 10), - count - }; -}); +export const contributionValues = Array.from( + { length: contributionNumDays }, + (_, index) => { + const end = new Date(`${contributionEndDate}T00:00:00.000Z`); + const date = new Date( + end.valueOf() - (contributionNumDays - 1 - index) * 24 * 60 * 60 * 1000 + ); + const weekday = date.getUTCDay(); + const cycle = (index * 7 + weekday * 3) % 17; + const launchWeekBoost = index > 110 && index < 126 ? 8 : 0; + const weekendDip = weekday === 0 || weekday === 6 ? -4 : 0; + const count = Math.max(0, cycle + launchWeekBoost + weekendDip); + + return { + date: date.toISOString().slice(0, 10), + count + }; + } +); + +export const candlebarPrices = [ + { date: "09:30", open: 184, high: 196, low: 179, close: 191, volume: 42 }, + { date: "10:00", open: 191, high: 208, low: 188, close: 202, volume: 68 }, + { date: "10:30", open: 202, high: 206, low: 185, close: 189, volume: 74 }, + { date: "11:00", open: 189, high: 215, low: 186, close: 211, volume: 95 }, + { date: "11:30", open: 211, high: 226, low: 204, close: 219, volume: 88 }, + { date: "12:00", open: 219, high: 221, low: 198, close: 204, volume: 81 }, + { date: "12:30", open: 204, high: 232, low: 201, close: 228, volume: 118 }, + { date: "13:00", open: 228, high: 236, low: 214, close: 217, volume: 101 }, + { date: "13:30", open: 217, high: 244, low: 216, close: 239, volume: 132 }, + { date: "14:00", open: 239, high: 248, low: 226, close: 231, volume: 109 }, + { date: "14:30", open: 231, high: 252, low: 229, close: 247, volume: 124 }, + { date: "15:00", open: 247, high: 258, low: 236, close: 241, volume: 116 } +]; + +export const radarBenchmarks = [ + { metric: "Speed", current: 82, target: 92, industry: 68 }, + { metric: "Polish", current: 76, target: 88, industry: 61 }, + { metric: "A11y", current: 90, target: 94, industry: 72 }, + { metric: "Depth", current: 68, target: 84, industry: 55 }, + { metric: "Control", current: 86, target: 91, industry: 65 }, + { metric: "Export", current: 72, target: 86, industry: 58 } +]; + +export const comboRevenue = [ + { month: "Jan", revenue: 420, forecast: 480, margin: 128 }, + { month: "Feb", revenue: 560, forecast: 530, margin: 168 }, + { month: "Mar", revenue: 490, forecast: 610, margin: 151 }, + { month: "Apr", revenue: 720, forecast: 690, margin: 214 }, + { month: "May", revenue: 640, forecast: 760, margin: 193 }, + { month: "Jun", revenue: 880, forecast: 840, margin: 276 }, + { month: "Jul", revenue: 790, forecast: 920, margin: 244 }, + { month: "Aug", revenue: 1040, forecast: 980, margin: 331 } +]; diff --git a/apps/site/src/previews/examples.tsx b/apps/site/src/previews/examples.tsx index d4eb5fd0..8b53a086 100644 --- a/apps/site/src/previews/examples.tsx +++ b/apps/site/src/previews/examples.tsx @@ -3,6 +3,7 @@ import { Text, View } from "react-native"; import { ChartKitProvider, + type CartesianChartPresetValue, type ChartKitThemeMode } from "react-native-chart-kit/v2"; @@ -10,18 +11,25 @@ import { chartPreviewExamples } from "./registry"; import { showcaseCustomPresets } from "./showcaseTheme"; type PreviewRenderContext = { + chartThemePreset: CartesianChartPresetValue; mode: Exclude; width: number; }; export type ChartPreviewExample = { + ctaHref?: string; + ctaLabel?: string; + description?: string; eyebrow: string; id: string; render: (context: PreviewRenderContext) => ReactNode; + supportsChartTheme?: boolean; + tier?: "free" | "pro"; title: string; }; export const renderChartPreview = ({ + chartThemePreset, id, mode, width @@ -36,15 +44,31 @@ export const renderChartPreview = ({ ); } + const preset = + example.supportsChartTheme === false ? "default" : chartThemePreset; + return ( - {example.eyebrow} + + {example.eyebrow} + {example.tier === "pro" ? ( + + Pro + + ) : null} + {example.title} + {example.description ? ( + + {example.description} + + ) : null} - {example.render({ mode, width: width - 2 })} + {example.render({ chartThemePreset, mode, width: width - 2 })} + {example.tier === "pro" ? ( +
+ {example.ctaLabel ?? "View Pro plans"} + + ) : null} ); @@ -77,6 +119,24 @@ const previewStyles = { header: { marginBottom: 16 }, + description: { + color: "rgba(7, 23, 51, 0.62)", + fontSize: 14, + fontWeight: "400" as const, + letterSpacing: 0, + lineHeight: 21, + marginTop: 8, + maxWidth: 540 + }, + descriptionDark: { + color: "rgba(255, 255, 255, 0.62)" + }, + metaRow: { + alignItems: "center" as const, + flexDirection: "row" as const, + gap: 8, + marginBottom: 8 + }, missing: { backgroundColor: "#ffffff", borderColor: "rgba(16, 18, 23, 0.14)", @@ -92,6 +152,25 @@ const previewStyles = { shell: { width: "100%" as const }, + proBadge: { + backgroundColor: "rgba(15, 58, 120, 0.1)", + borderColor: "rgba(15, 58, 120, 0.18)", + borderRadius: 999, + borderWidth: 1, + color: "#0f3a78", + fontSize: 11, + fontWeight: "800" as const, + letterSpacing: 0, + lineHeight: 18, + overflow: "hidden" as const, + paddingHorizontal: 8, + textTransform: "uppercase" as const + }, + proBadgeDark: { + backgroundColor: "rgba(216, 230, 255, 0.1)", + borderColor: "rgba(216, 230, 255, 0.18)", + color: "#d8e6ff" + }, title: { color: "#101217", fontSize: 28, diff --git a/apps/site/src/previews/proStub.tsx b/apps/site/src/previews/proStub.tsx new file mode 100644 index 00000000..0e0f4846 --- /dev/null +++ b/apps/site/src/previews/proStub.tsx @@ -0,0 +1,683 @@ +import React, { useEffect, useState } from "react"; +import { Text, View } from "react-native"; +import Svg, { + Circle, + G, + Line, + Path, + Polygon, + Rect, + Text as SvgText +} from "react-native-svg"; + +type ChartDatum = Record; + +type ProChartBaseProps = { + data: TData[]; + defaultSelectedIndex?: number; + height: number; + width: number; +}; + +type CandlebarChartProps = + ProChartBaseProps & { + closeKey?: string; + dateKey?: string; + highKey?: string; + lowKey?: string; + openKey?: string; + volumeKey?: string; + }; + +type RadarSeries = { + color?: string; + label?: string; + valueKey: string; +}; + +type RadarChartProps = + ProChartBaseProps & { + categoryKey?: string; + maxValue?: number; + series?: RadarSeries[]; + }; + +type ComboSeries = { + color?: string; + label?: string; + type: "bar" | "line"; + yKey: string; +}; + +type ComboChartProps = + ProChartBaseProps & { + series?: ComboSeries[]; + xKey?: string; + }; + +type ThemeMode = "dark" | "light"; + +const proPalette = ["#4f8cff", "#18b7a0", "#f59e0b", "#d946ef"]; + +const getThemeMode = (): ThemeMode => + typeof document !== "undefined" && + document.documentElement.dataset.theme === "light" + ? "light" + : "dark"; + +const useThemeMode = () => { + const [mode, setMode] = useState(() => getThemeMode()); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + const updateMode = () => setMode(getThemeMode()); + const observer = new MutationObserver(updateMode); + observer.observe(document.documentElement, { + attributeFilter: ["data-theme"] + }); + updateMode(); + + return () => observer.disconnect(); + }, []); + + return mode; +}; + +const getTheme = (mode: ThemeMode) => + mode === "light" + ? { + axis: "rgba(7, 23, 51, 0.22)", + card: "rgba(7, 23, 51, 0.04)", + grid: "rgba(7, 23, 51, 0.09)", + label: "rgba(7, 23, 51, 0.52)", + muted: "rgba(7, 23, 51, 0.42)", + negative: "#e14d4d", + positive: "#17a37b", + text: "#071733", + tooltip: "#ffffff" + } + : { + axis: "rgba(255, 255, 255, 0.24)", + card: "rgba(255, 255, 255, 0.055)", + grid: "rgba(255, 255, 255, 0.1)", + label: "rgba(255, 255, 255, 0.62)", + muted: "rgba(255, 255, 255, 0.42)", + negative: "#ff6b72", + positive: "#2dd4a7", + text: "#f7f8f8", + tooltip: "#111827" + }; + +const toNumber = (value: unknown, fallback = 0) => { + const number = typeof value === "number" ? value : Number(value); + + return Number.isFinite(number) ? number : fallback; +}; + +const toLabel = (value: unknown) => + value === undefined || value === null ? "" : String(value); + +const clampIndex = (index: number | undefined, length: number) => + Math.min( + Math.max(index ?? Math.floor(length / 2), 0), + Math.max(length - 1, 0) + ); + +const linePath = (points: Array<{ x: number; y: number }>) => + points + .map( + (point, index) => + `${index === 0 ? "M" : "L"}${point.x.toFixed(2)} ${point.y.toFixed(2)}` + ) + .join(" "); + +const chartShellStyle = { + alignItems: "center" as const, + gap: 10, + justifyContent: "center" as const +}; + +const captionStyle = (mode: ThemeMode) => ({ + color: getTheme(mode).label, + fontSize: 12, + fontWeight: "600" as const +}); + +export const CandlebarChart = ({ + closeKey = "close", + data, + dateKey = "date", + defaultSelectedIndex, + height, + highKey = "high", + lowKey = "low", + openKey = "open", + volumeKey = "volume", + width +}: CandlebarChartProps) => { + const mode = useThemeMode(); + const theme = getTheme(mode); + const [selectedIndex, setSelectedIndex] = useState(() => + clampIndex(defaultSelectedIndex, data.length) + ); + const selected = data[selectedIndex]; + const frame = { + bottom: height - 50, + left: 44, + right: width - 18, + top: 20 + }; + const plotHeight = frame.bottom - frame.top; + const values = data.flatMap((row) => [ + toNumber(row[highKey], 0), + toNumber(row[lowKey], 0) + ]); + const minValue = Math.min(...values); + const maxValue = Math.max(...values); + const range = Math.max(maxValue - minValue, 1); + const step = data.length > 1 ? (frame.right - frame.left) / data.length : 22; + const candleWidth = Math.max(8, Math.min(18, step * 0.58)); + const y = (value: number) => + frame.top + ((maxValue - value) / range) * plotHeight; + const maxVolume = Math.max( + 1, + ...data.map((row) => toNumber(row[volumeKey], 0)) + ); + + return ( + + + + {[0, 0.5, 1].map((tick) => { + const tickY = frame.top + tick * plotHeight; + + return ( + + + + {Math.round(maxValue - tick * range)} + + + ); + })} + {data.map((row, index) => { + const open = toNumber(row[openKey]); + const close = toNumber(row[closeKey]); + const high = toNumber(row[highKey]); + const low = toNumber(row[lowKey]); + const volume = toNumber(row[volumeKey]); + const centerX = frame.left + index * step + step / 2; + const top = Math.min(y(open), y(close)); + const bodyHeight = Math.max(Math.abs(y(open) - y(close)), 3); + const rising = close >= open; + const color = rising ? theme.positive : theme.negative; + const selectedCandle = index === selectedIndex; + const volumeHeight = (volume / maxVolume) * 28; + + return ( + setSelectedIndex(index)} + > + + + + + + ); + })} + {selected ? ( + + + + + {toLabel(selected[dateKey])} + + + O {toNumber(selected[openKey])} / C {toNumber(selected[closeKey])} + + + ) : null} + + Tap candles to inspect OHLC values + + ); +}; + +export const CandlestickChart = CandlebarChart; + +export const RadarChart = ({ + categoryKey = "metric", + data, + height, + maxValue, + series = [ + { color: proPalette[0], label: "Current", valueKey: "current" }, + { color: proPalette[1], label: "Target", valueKey: "target" } + ] as RadarSeries[], + width +}: RadarChartProps) => { + const mode = useThemeMode(); + const theme = getTheme(mode); + const [selectedIndex, setSelectedIndex] = useState(0); + const center = { x: width / 2, y: height / 2 + 6 }; + const radius = Math.min(width, height) * 0.32; + const max = + maxValue ?? + Math.max( + 1, + ...data.flatMap((row) => + series.map((item) => toNumber(row[item.valueKey], 0)) + ) + ); + const pointFor = (index: number, value: number) => { + const angle = -Math.PI / 2 + (index / data.length) * Math.PI * 2; + const distance = (value / max) * radius; + + return { + x: center.x + Math.cos(angle) * distance, + y: center.y + Math.sin(angle) * distance + }; + }; + const axisPoint = (index: number, distance = radius) => { + const angle = -Math.PI / 2 + (index / data.length) * Math.PI * 2; + + return { + x: center.x + Math.cos(angle) * distance, + y: center.y + Math.sin(angle) * distance + }; + }; + + return ( + + + + {[0.25, 0.5, 0.75, 1].map((ring) => ( + { + const point = axisPoint(index, radius * ring); + + return `${point.x.toFixed(2)},${point.y.toFixed(2)}`; + }) + .join(" ")} + stroke={theme.grid} + strokeWidth={1} + /> + ))} + {data.map((row, index) => { + const point = axisPoint(index); + const labelPoint = axisPoint(index, radius + 20); + + return ( + + + setSelectedIndex(index)} + r={20} + /> + + {toLabel(row[categoryKey])} + + + ); + })} + {series.map((item, seriesIndex) => { + const points = data.map((row, index) => + pointFor(index, toNumber(row[item.valueKey])) + ); + const pathPoints = points + .map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`) + .join(" "); + + return ( + + + {points.map((point, index) => ( + + ))} + + ); + })} + + {toLabel(data[selectedIndex]?.[categoryKey])} + + + Tap an axis to focus a KPI + + ); +}; + +export const ComboChart = ({ + data, + defaultSelectedIndex, + height, + series = [ + { color: proPalette[0], label: "Revenue", type: "bar", yKey: "revenue" }, + { color: proPalette[2], label: "Forecast", type: "line", yKey: "forecast" } + ] as ComboSeries[], + width, + xKey = "month" +}: ComboChartProps) => { + const mode = useThemeMode(); + const theme = getTheme(mode); + const [selectedIndex, setSelectedIndex] = useState(() => + clampIndex(defaultSelectedIndex, data.length) + ); + const frame = { + bottom: height - 36, + left: 42, + right: width - 18, + top: 20 + }; + const plotHeight = frame.bottom - frame.top; + const values = data.flatMap((row) => + series.map((item) => toNumber(row[item.yKey], 0)) + ); + const maxValue = Math.max(1, ...values); + const step = data.length > 1 ? (frame.right - frame.left) / data.length : 28; + const y = (value: number) => + frame.top + ((maxValue - value) / maxValue) * plotHeight; + const barSeries = series.filter((item) => item.type === "bar"); + const lineSeries = series.filter((item) => item.type === "line"); + const barWidth = Math.max(10, Math.min(26, step * 0.5)); + const selected = data[selectedIndex]; + const tooltipX = Math.min( + Math.max(frame.left + selectedIndex * step - 20, 8), + width - 128 + ); + + const lineModels = lineSeries.map((item) => ({ + item, + points: data.map((row, index) => ({ + x: frame.left + index * step + step / 2, + y: y(toNumber(row[item.yKey])) + })) + })); + + return ( + + + + {[0, 0.33, 0.66, 1].map((tick) => { + const tickY = frame.top + tick * plotHeight; + + return ( + + ); + })} + {data.map((row, index) => { + const x = frame.left + index * step + step / 2; + + return ( + + setSelectedIndex(index)} + width={step} + x={frame.left + index * step} + y={0} + /> + {barSeries.map((item, barIndex) => { + const value = toNumber(row[item.yKey]); + const barHeight = frame.bottom - y(value); + const xOffset = + x - (barWidth * barSeries.length) / 2 + barIndex * barWidth; + + return ( + + ); + })} + + {toLabel(row[xKey])} + + + ); + })} + {lineModels.map(({ item, points }, index) => ( + + + {points.map((point, pointIndex) => ( + + ))} + + ))} + {selected ? ( + + + + + {toLabel(selected[xKey])} + + + {series + .slice(0, 2) + .map( + (item) => + `${item.label ?? String(item.yKey)} ${selected ? toNumber(selected[item.yKey]) : 0}` + ) + .join(" / ")} + + + ) : null} + + + Tap a month to inspect blended series + + + ); +}; diff --git a/apps/site/src/previews/reactNativeWebStub.tsx b/apps/site/src/previews/reactNativeWebStub.tsx index 7fc07bdc..7adc0286 100644 --- a/apps/site/src/previews/reactNativeWebStub.tsx +++ b/apps/site/src/previews/reactNativeWebStub.tsx @@ -7,6 +7,13 @@ type AnyProps = Record; type ScrollHandle = { scrollTo: (options?: { animated?: boolean; x?: number; y?: number }) => void; }; +type ScrollDragState = { + active: boolean; + pointerId: number; + scrollLeft: number; + startX: number; + startY: number; +}; type ResponderEvent = { currentTarget: EventTarget & Element; nativeEvent: { @@ -71,10 +78,17 @@ type ActiveResponder = kind: "responder"; pointerId: number; }; +type PendingResponderPointer = { + pointerId: number; + startClientX: number; + startClientY: number; + startTime: number; +}; type MutableRef = { current: T; }; type PressableState = { focused: boolean; hovered: boolean; pressed: boolean }; +type PointerEventsValue = "auto" | "box-none" | "box-only" | "none"; type StyleValue = | AnyProps | false @@ -92,6 +106,7 @@ const responderProps = new Set([ "keyboardShouldPersistTaps", "nestedScrollEnabled", "onMoveShouldSetPanResponder", + "onMoveShouldSetPanResponderCapture", "onMoveShouldSetResponder", "onMoveShouldSetResponderCapture", "onPanResponderGrant", @@ -105,6 +120,7 @@ const responderProps = new Set([ "onResponderTerminationRequest", "onShouldBlockNativeResponder", "onStartShouldSetPanResponder", + "onStartShouldSetPanResponderCapture", "onStartShouldSetResponder", "onStartShouldSetResponderCapture", "numberOfLines", @@ -236,10 +252,26 @@ const shouldUseResponder = ( phase === "start" ? (props.onStartShouldSetPanResponder as PanResponderPredicate) : (props.onMoveShouldSetPanResponder as PanResponderPredicate); + const panCapturePredicate = + phase === "start" + ? (props.onStartShouldSetPanResponderCapture as PanResponderPredicate) + : (props.onMoveShouldSetPanResponderCapture as PanResponderPredicate); const responderPredicate = phase === "start" ? (props.onStartShouldSetResponder as ResponderPredicate) : (props.onMoveShouldSetResponder as ResponderPredicate); + const responderCapturePredicate = + phase === "start" + ? (props.onStartShouldSetResponderCapture as ResponderPredicate) + : (props.onMoveShouldSetResponderCapture as ResponderPredicate); + + if (panCapturePredicate?.(event, gestureState)) { + return "pan"; + } + + if (responderCapturePredicate?.(event)) { + return "responder"; + } if (panPredicate?.(event, gestureState)) { return "pan"; @@ -255,7 +287,8 @@ const shouldUseResponder = ( const addResponderDomHandlers = ( sourceProps: AnyProps, domProps: AnyProps, - activeResponderRef: MutableRef + activeResponderRef: MutableRef, + pendingPointerRef: MutableRef ) => { const hasResponderProps = Array.from(responderProps).some( (key) => sourceProps[key] !== undefined @@ -344,13 +377,21 @@ const addResponderDomHandlers = ( return; } + const pendingPointer = { + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + startTime: Date.now() + }; + pendingPointerRef.current = pendingPointer; + const responderEvent = createResponderEvent(event); const gestureState = createGestureState({ clientX: event.clientX, clientY: event.clientY, - startClientX: event.clientX, - startClientY: event.clientY, - startTime: Date.now(), + startClientX: pendingPointer.startClientX, + startClientY: pendingPointer.startClientY, + startTime: pendingPointer.startTime, stateID: event.pointerId }); const responderKind = shouldUseResponder( @@ -371,14 +412,25 @@ const addResponderDomHandlers = ( const activeResponder = activeResponderRef.current; if (!activeResponder) { + if (event.buttons === 0) { + pendingPointerRef.current = undefined; + return; + } + + const pendingPointer = pendingPointerRef.current; + + if (!pendingPointer || pendingPointer.pointerId !== event.pointerId) { + return; + } + const responderEvent = createResponderEvent(event); const gestureState = createGestureState({ clientX: event.clientX, clientY: event.clientY, - startClientX: event.clientX, - startClientY: event.clientY, - startTime: Date.now(), - stateID: event.pointerId + startClientX: pendingPointer.startClientX, + startClientY: pendingPointer.startClientY, + startTime: pendingPointer.startTime, + stateID: pendingPointer.pointerId }); const responderKind = shouldUseResponder( sourceProps, @@ -427,12 +479,20 @@ const addResponderDomHandlers = ( domProps.onPointerUp = (event: React.PointerEvent) => { (sourceProps.onPointerUp as React.PointerEventHandler)?.(event); finishResponder(event); + + if (pendingPointerRef.current?.pointerId === event.pointerId) { + pendingPointerRef.current = undefined; + } }; domProps.onPointerCancel = (event: React.PointerEvent) => { (sourceProps.onPointerCancel as React.PointerEventHandler)?.( event ); finishResponder(event, true); + + if (pendingPointerRef.current?.pointerId === event.pointerId) { + pendingPointerRef.current = undefined; + } }; }; @@ -475,6 +535,16 @@ const flattenStyle = (style: StyleValue): AnyProps | undefined => { return undefined; }; +const getCssPointerEvents = (pointerEvents: PointerEventsValue | undefined) => { + if (pointerEvents === "none" || pointerEvents === "box-none") { + return "none"; + } + + return pointerEvents === "box-only" || pointerEvents === "auto" + ? "auto" + : undefined; +}; + const toDomProps = (props: AnyProps = {}) => { const { accessibilityLabel, @@ -483,6 +553,7 @@ const toDomProps = (props: AnyProps = {}) => { collapsable: _collapsable, hitSlop: _hitSlop, nativeID, + pointerEvents, pressRetentionOffset: _pressRetentionOffset, style, testID, @@ -514,7 +585,14 @@ const toDomProps = (props: AnyProps = {}) => { domProps["data-testid"] = testID; } - domProps.style = flattenStyle(style as StyleValue); + const flatStyle = flattenStyle(style as StyleValue); + const cssPointerEvents = getCssPointerEvents( + pointerEvents as PointerEventsValue | undefined + ); + domProps.style = { + ...(flatStyle ?? {}), + ...(cssPointerEvents ? { pointerEvents: cssPointerEvents } : {}) + }; return domProps; }; @@ -527,9 +605,17 @@ const createPrimitive = ( const activeResponderRef = React.useRef( undefined ); + const pendingPointerRef = React.useRef( + undefined + ); const domProps = toDomProps(props); domProps.style = { ...defaultStyle, ...(domProps.style as AnyProps) }; - addResponderDomHandlers(props, domProps, activeResponderRef); + addResponderDomHandlers( + props, + domProps, + activeResponderRef, + pendingPointerRef + ); addTouchDomHandlers(props, domProps); return React.createElement(tag, { ...domProps, ref }); @@ -589,11 +675,13 @@ export const ScrollView = React.forwardRef( ref ) => { const elementRef = React.useRef(null); + const scrollDragRef = React.useRef(undefined); const handleScroll = onScroll as | ((event: { nativeEvent: { contentOffset: { x: number; y: number } }; }) => void) | undefined; + const domProps = toDomProps({ ...props, style }); React.useImperativeHandle(ref, () => ({ scrollTo: ({ animated: _animated, x = 0, y = 0 } = {}) => { @@ -604,7 +692,90 @@ export const ScrollView = React.forwardRef( return React.createElement( "div", { - ...toDomProps({ ...props, style }), + ...domProps, + onPointerCancel: (event: React.PointerEvent) => { + ( + domProps.onPointerCancel as + | React.PointerEventHandler + | undefined + )?.(event); + + if (scrollDragRef.current?.pointerId === event.pointerId) { + event.currentTarget.releasePointerCapture?.(event.pointerId); + scrollDragRef.current = undefined; + } + }, + onPointerDown: (event: React.PointerEvent) => { + ( + domProps.onPointerDown as + | React.PointerEventHandler + | undefined + )?.(event); + + if ( + !horizontal || + event.button !== 0 || + event.defaultPrevented || + event.currentTarget.scrollWidth <= event.currentTarget.clientWidth + ) { + return; + } + + scrollDragRef.current = { + active: false, + pointerId: event.pointerId, + scrollLeft: event.currentTarget.scrollLeft, + startX: event.clientX, + startY: event.clientY + }; + event.currentTarget.setPointerCapture?.(event.pointerId); + }, + onPointerMove: (event: React.PointerEvent) => { + ( + domProps.onPointerMove as + | React.PointerEventHandler + | undefined + )?.(event); + + const drag = scrollDragRef.current; + + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + + const deltaX = event.clientX - drag.startX; + const deltaY = event.clientY - drag.startY; + const absoluteDeltaX = Math.abs(deltaX); + const absoluteDeltaY = Math.abs(deltaY); + + if (!drag.active) { + if (absoluteDeltaX < 3) { + return; + } + + if (absoluteDeltaY > absoluteDeltaX) { + event.currentTarget.releasePointerCapture?.(event.pointerId); + scrollDragRef.current = undefined; + return; + } + } + + drag.active = true; + event.currentTarget.scrollLeft = drag.scrollLeft - deltaX; + event.preventDefault(); + }, + onPointerUp: (event: React.PointerEvent) => { + ( + domProps.onPointerUp as + | React.PointerEventHandler + | undefined + )?.(event); + + if (scrollDragRef.current?.pointerId === event.pointerId) { + event.currentTarget.releasePointerCapture?.(event.pointerId); + scrollDragRef.current = undefined; + } + }, onScroll: (event: React.UIEvent) => { handleScroll?.({ nativeEvent: { @@ -617,9 +788,9 @@ export const ScrollView = React.forwardRef( }, ref: elementRef, style: { + ...(domProps.style as AnyProps), overflowX: horizontal ? "auto" : "hidden", - overflowY: horizontal ? "hidden" : "auto", - ...(flattenStyle(style as StyleValue) ?? {}) + overflowY: horizontal ? "hidden" : "auto" } }, React.createElement( diff --git a/apps/site/src/previews/registry.tsx b/apps/site/src/previews/registry.tsx index 53b7ce95..cc9da711 100644 --- a/apps/site/src/previews/registry.tsx +++ b/apps/site/src/previews/registry.tsx @@ -9,11 +9,16 @@ import { PieChart, ProgressChart } from "react-native-chart-kit/v2"; +import { CandlebarChart, ComboChart, RadarChart } from "@chart-kit/pro"; import type { ChartPreviewExample } from "./examples"; import { acquisitionShare, + candlebarPrices, clampChartWidth, + comboRevenue, + contributionEndDate, + contributionNumDays, contributionValues, money, monthRevenue, @@ -21,6 +26,7 @@ import { platformShare, profit, progressRings, + radarBenchmarks, revenueMix, signedMoney, signups, @@ -207,7 +213,7 @@ export const chartPreviewExamples: Record = { title: "Revenue mix", render: ({ width }) => ( = { "progress-rings": { eyebrow: "Progress", id: "progress-rings", - title: "Activation health", + title: "Release checklist", render: ({ width }) => ( `${Math.round(average * 100)}%`} @@ -241,7 +247,7 @@ export const chartPreviewExamples: Record = { render: ({ width }) => ( = { render: ({ width }) => ( @@ -270,12 +276,86 @@ export const chartPreviewExamples: Record = { title: "No activity", render: ({ width }) => ( ) + }, + "pro-candlebar": { + ctaHref: "/#pricing", + description: + "OHLC inspection, volume context, and mobile-friendly hit targets for trading and market screens.", + eyebrow: "Financial", + id: "pro-candlebar", + tier: "pro", + title: "Candlebar session", + render: ({ width }) => ( + + ) + }, + "pro-radar": { + ctaHref: "/#pricing", + description: + "Compare multiple KPI profiles with readable axes, polygon fills, and selection-ready metric focus.", + eyebrow: "Benchmarking", + id: "pro-radar", + tier: "pro", + title: "Release quality radar", + render: ({ width }) => ( + + ) + }, + "pro-combo": { + ctaHref: "/#pricing", + description: + "Blend bars and lines on one coordinated surface for revenue, forecast, and margin workflows.", + eyebrow: "Mixed series", + id: "pro-combo", + tier: "pro", + title: "Revenue operating view", + render: ({ width }) => ( + + ) } }; diff --git a/apps/site/src/styles/starlight.css b/apps/site/src/styles/starlight.css index b04a4952..bf0ea447 100644 --- a/apps/site/src/styles/starlight.css +++ b/apps/site/src/styles/starlight.css @@ -7,3 +7,1206 @@ border-top-left-radius: calc(var(--ec-brdRad) + var(--ec-brdWd)); border-top-right-radius: calc(var(--ec-brdRad) + var(--ec-brdWd)); } + +:root { + --sl-font: "Inter", system-ui, sans-serif; + --sl-content-width: 58rem; + --sl-sidebar-width: 17rem; + --sl-content-pad-x: 2.5rem; + --sl-content-gap-y: 1.15rem; + --sl-line-height: 1.72; + --sl-line-height-headings: 1.08; + --sl-text-h1: 3.25rem; + --sl-text-h2: 2.05rem; + --sl-text-h3: 1.45rem; + --sl-color-accent-low: #13213a; + --sl-color-accent: #78a8ff; + --sl-color-accent-high: #d8e6ff; + --sl-color-text-accent: #d8e6ff; + --sl-color-text: rgba(255, 255, 255, 0.68); + --sl-color-white: #ffffff; + --sl-color-gray-1: rgba(255, 255, 255, 0.9); + --sl-color-gray-2: rgba(255, 255, 255, 0.68); + --sl-color-gray-3: rgba(255, 255, 255, 0.46); + --sl-color-gray-4: rgba(255, 255, 255, 0.28); + --sl-color-gray-5: rgba(255, 255, 255, 0.14); + --sl-color-gray-6: #171a21; + --sl-color-black: #08090d; + --sl-color-bg: #0b0c11; + --sl-color-bg-nav: #08090d; + --sl-color-bg-sidebar: #11131a; + --sl-color-bg-inline-code: rgba(255, 255, 255, 0.09); + --sl-color-hairline: rgba(255, 255, 255, 0.1); + --sl-color-hairline-light: rgba(255, 255, 255, 0.1); + --sl-color-hairline-shade: rgba(255, 255, 255, 0.1); + --sl-shadow-sm: none; + --sl-shadow-md: none; + --sl-shadow-lg: none; +} + +:root[data-theme="light"] { + --sl-color-accent-low: #eaf2ff; + --sl-color-accent: #2563eb; + --sl-color-accent-high: #0f3a78; + --sl-color-text-accent: #0f3a78; + --sl-color-text: rgba(7, 23, 51, 0.7); + --sl-color-white: #071733; + --sl-color-gray-1: rgba(7, 23, 51, 0.88); + --sl-color-gray-2: rgba(7, 23, 51, 0.68); + --sl-color-gray-3: rgba(7, 23, 51, 0.48); + --sl-color-gray-4: rgba(7, 23, 51, 0.28); + --sl-color-gray-5: rgba(7, 23, 51, 0.12); + --sl-color-gray-6: #edf3fb; + --sl-color-gray-7: #f8fbff; + --sl-color-black: #ffffff; + --sl-color-bg: #f6f9fd; + --sl-color-bg-nav: #ffffff; + --sl-color-bg-sidebar: #ffffff; + --sl-color-bg-inline-code: rgba(7, 23, 51, 0.07); + --sl-color-hairline: rgba(7, 23, 51, 0.1); + --sl-color-hairline-light: rgba(7, 23, 51, 0.1); + --sl-color-hairline-shade: rgba(7, 23, 51, 0.1); + --sl-shadow-sm: none; + --sl-shadow-md: none; + --sl-shadow-lg: none; +} + +html { + background: var(--sl-color-bg); +} + +body { + background: var(--sl-color-bg); +} + +.site-title { + color: var(--sl-color-white); + gap: 0.75rem; + font-size: 0.94rem; + font-weight: 700; +} + +.site-title::before { + display: grid; + width: 2rem; + height: 2rem; + flex: 0 0 auto; + place-items: center; + border-radius: 0.5rem; + background: #ffffff; + color: #05060a; + content: "CK"; + font-size: 0.82rem; + font-weight: 800; +} + +:root[data-theme="light"] .site-title::before { + background: #071733; + color: #ffffff; +} + +button[data-open-modal] { + border-radius: 0.75rem; +} + +@media (min-width: 50rem) { + button[data-open-modal] { + border-color: var(--sl-color-hairline); + background-color: rgba(255, 255, 255, 0.035); + max-width: 20rem; + } + + :root[data-theme="light"] button[data-open-modal] { + background-color: rgba(7, 23, 51, 0.035); + } +} + +.social-icons::after { + border-color: var(--sl-color-hairline); +} + +.chartkit-chart-theme-control { + display: inline-flex; + align-items: center; + gap: 0.45rem; + margin-inline-end: 0.25rem; + color: var(--sl-color-gray-3); + font-size: 0.72rem; + font-weight: 700; + line-height: 1; + white-space: nowrap; +} + +.chartkit-chart-theme-control__label { + color: inherit; +} + +.chartkit-chart-theme-control__select { + height: 2rem; + min-width: 8.6rem; + border: 1px solid var(--sl-color-hairline); + border-radius: 0.55rem; + background: rgba(255, 255, 255, 0.035); + color: var(--sl-color-white); + cursor: pointer; + font: inherit; + font-size: 0.74rem; + font-weight: 650; + line-height: 1; + padding: 0 0.65rem; +} + +.chartkit-chart-theme-control__select:hover, +.chartkit-chart-theme-control__select:focus { + border-color: rgba(255, 255, 255, 0.18); + color: var(--sl-color-white); +} + +.chartkit-chart-theme-control__select:focus-visible { + outline: 2px solid rgba(120, 168, 255, 0.55); + outline-offset: 2px; +} + +.chartkit-chart-theme-control__select option { + background: var(--sl-color-bg); + color: var(--sl-color-white); +} + +:root[data-theme="light"] .chartkit-chart-theme-control { + color: rgba(7, 23, 51, 0.48); +} + +:root[data-theme="light"] .chartkit-chart-theme-control__select { + background: rgba(7, 23, 51, 0.035); + color: #071733; +} + +:root[data-theme="light"] .chartkit-chart-theme-control__select:hover, +:root[data-theme="light"] .chartkit-chart-theme-control__select:focus { + border-color: rgba(7, 23, 51, 0.18); + color: #071733; +} + +:root[data-theme="light"] .chartkit-chart-theme-control__select option { + background: #ffffff; + color: #071733; +} + +starlight-theme-select { + display: inline-flex; + align-items: center; + justify-content: center; +} + +starlight-theme-select > label { + display: none; +} + +.chartkit-docs-theme-toggle { + display: inline-flex; + width: 2rem; + height: 2rem; + align-items: center; + justify-content: center; + border: 0; + border-radius: 0.45rem; + background: transparent; + color: var(--sl-color-gray-3); + cursor: pointer; + padding: 0; + transition: color 0.15s ease; +} + +.chartkit-docs-theme-toggle:hover, +.chartkit-docs-theme-toggle:focus-visible { + color: var(--sl-color-white); +} + +.chartkit-docs-theme-toggle:focus-visible { + outline: 2px solid rgba(120, 168, 255, 0.55); + outline-offset: 2px; +} + +.chartkit-docs-theme-toggle svg { + display: block; + width: 1.05rem; + height: 1.05rem; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2.25; +} + +:root[data-theme="light"] .chartkit-docs-theme-toggle { + color: rgba(7, 23, 51, 0.48); +} + +:root[data-theme="light"] .chartkit-docs-theme-toggle:hover, +:root[data-theme="light"] .chartkit-docs-theme-toggle:focus-visible { + color: #071733; +} + +@media (min-width: 50rem) { + .sidebar-pane { + background: var(--sl-color-bg-sidebar); + } + + :root[data-theme="light"] .sidebar-pane { + background: var(--sl-color-bg-sidebar); + } +} + +.sidebar-content { + padding-top: 1.25rem; +} + +.sidebar-content .large { + color: var(--sl-color-white); + font-size: 0.9rem; + font-weight: 700; +} + +.sidebar-content summary { + border-radius: 0.65rem; + padding: 0.45rem 0.55rem; +} + +.sidebar-content summary:hover { + background: rgba(255, 255, 255, 0.045); +} + +:root[data-theme="light"] .sidebar-content summary:hover { + background: rgba(7, 23, 51, 0.045); +} + +.sidebar-content a { + display: flex; + min-height: 2rem; + align-items: center; + border-radius: 0.55rem; + color: var(--sl-color-gray-2); + font-size: 0.83rem; + font-weight: 650; + line-height: 1.25; + padding: 0.45rem 0.55rem; +} + +.sidebar-content a span { + font-size: inherit; + font-weight: inherit; + line-height: inherit; +} + +.sidebar-content a:hover, +.sidebar-content a:focus { + background: rgba(255, 255, 255, 0.045); + color: var(--sl-color-white); +} + +:root[data-theme="light"] .sidebar-content a:hover, +:root[data-theme="light"] .sidebar-content a:focus { + background: rgba(7, 23, 51, 0.045); +} + +.sidebar-content [aria-current="page"], +.sidebar-content [aria-current="page"]:hover, +.sidebar-content [aria-current="page"]:focus { + background: rgba(120, 168, 255, 0.14); + color: #d8e6ff; + font-size: 0.83rem; + font-weight: 650; + line-height: 1.25; +} + +:root[data-theme="light"] .sidebar-content [aria-current="page"], +:root[data-theme="light"] .sidebar-content [aria-current="page"]:hover, +:root[data-theme="light"] .sidebar-content [aria-current="page"]:focus { + background: #eaf2ff; + color: #0f3a78; +} + +.main-frame { + background: transparent; +} + +.content-panel { + border-color: var(--sl-color-hairline); +} + +.content-panel:first-child { + padding-top: 1.45rem; +} + +@media (min-width: 72rem) { + :root { + --sl-content-width: 62rem; + --sl-content-pad-x: 3.75rem; + --sl-sidebar-pad-x: 0.75rem; + --sl-sidebar-width: 16.25rem; + } + + .right-sidebar-container { + width: 14.5rem; + } + + .right-sidebar { + width: 14.5rem; + } + + [data-has-sidebar][data-has-toc] .main-pane { + --sl-content-margin-inline: auto; + + width: calc(100% - 14.5rem); + } + + .right-sidebar-panel { + padding-inline: 0.75rem; + } + + .right-sidebar-panel .sl-container { + width: auto; + max-width: 13rem; + } +} + +@media (max-width: 71.99rem) { + :root { + --sl-content-pad-x: 3rem; + } +} + +@media (min-width: 92rem) { + :root { + --sl-content-width: 66rem; + --sl-content-pad-x: 4.5rem; + } +} + +.sl-markdown-content { + color: var(--sl-color-text); +} + +h1#_top, +.sl-markdown-content h1:not(:where(.not-content *)) { + max-width: 20ch; + font-size: 2.45rem; + font-weight: 760; +} + +.sl-markdown-content h2:not(:where(.not-content *)) { + padding-top: 0.15rem; + font-size: 1.7rem; + font-weight: 760; +} + +.sl-markdown-content :is(h3, h4):not(:where(.not-content *)) { + font-weight: 730; +} + +.sl-markdown-content p:not(:where(.not-content *)), +.sl-markdown-content li:not(:where(.not-content *)) { + font-size: 0.98rem; +} + +.sl-markdown-content a:not(:where(.not-content *)) { + color: #9ec2ff; + font-weight: 650; + text-decoration-thickness: 1px; + text-underline-offset: 0.18em; +} + +:root[data-theme="light"] .sl-markdown-content a:not(:where(.not-content *)) { + color: #0f54c3; +} + +.sl-markdown-content code:not(:where(.not-content *)) { + border: 1px solid var(--sl-color-hairline); + border-radius: 0.38rem; + color: var(--sl-color-white); +} + +.sl-markdown-content pre:not(:where(.not-content *)), +.expressive-code figure.frame { + border-color: var(--sl-color-hairline); + border-radius: 0.8rem; + background: #1a1d25; +} + +:root[data-theme="light"] .sl-markdown-content pre:not(:where(.not-content *)), +:root[data-theme="light"] .expressive-code figure.frame { + background: #ffffff; +} + +.expressive-code figure.frame > pre { + border-radius: 0.8rem; +} + +.expressive-code .copy { + --chart-kit-code-copy-tooltip: "Copy to clipboard"; + --chart-kit-code-copy-icon: var(--ec-frm-copyIcon); + --chart-kit-code-check-icon: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2.4'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M20%206%209%2017l-5-5'%2F%3E%3C%2Fsvg%3E"); +} + +.expressive-code .copy:has(.feedback.show) { + --chart-kit-code-copy-tooltip: "Copied!"; +} + +.expressive-code .copy::before { + position: absolute; + z-index: 4; + right: 0; + top: calc(100% + 0.5rem); + width: max-content; + max-width: 11rem; + border: 1px solid var(--sl-color-hairline); + border-radius: 0.45rem; + background: #161a23; + color: var(--sl-color-white); + content: var(--chart-kit-code-copy-tooltip); + font-size: 0.72rem; + font-weight: 650; + line-height: 1; + opacity: 0; + padding: 0.45rem 0.55rem; + pointer-events: none; + transform: translateY(-0.2rem); + transition: + opacity 0.12s ease, + transform 0.12s ease; + visibility: hidden; +} + +.expressive-code .copy::after { + position: absolute; + z-index: 5; + right: 0.65rem; + top: calc(100% + 0.2rem); + width: 0.5rem; + height: 0.5rem; + border-top: 1px solid var(--sl-color-hairline); + border-left: 1px solid var(--sl-color-hairline); + background: #161a23; + content: ""; + opacity: 0; + pointer-events: none; + transform: translateY(-0.2rem) rotate(45deg); + transition: + opacity 0.12s ease, + transform 0.12s ease; + visibility: hidden; +} + +:root[data-theme="light"] .expressive-code .copy::before, +:root[data-theme="light"] .expressive-code .copy::after { + background: #ffffff; + color: #071733; +} + +.expressive-code .copy:has(button:hover)::before, +.expressive-code .copy:has(button:focus-visible)::before, +.expressive-code .copy:has(.feedback.show)::before, +.expressive-code .copy:has(button:hover)::after, +.expressive-code .copy:has(button:focus-visible)::after, +.expressive-code .copy:has(.feedback.show)::after { + opacity: 1; + transform: translateY(0); + visibility: visible; +} + +.expressive-code .copy:has(button:hover)::after, +.expressive-code .copy:has(button:focus-visible)::after, +.expressive-code .copy:has(.feedback.show)::after { + transform: translateY(0) rotate(45deg); +} + +.expressive-code .copy button { + width: 1.7rem; + height: 1.7rem; + border: 0; + border-radius: 0.45rem; + background: transparent; + color: var(--sl-color-gray-3); + opacity: 1; + transition-property: opacity, background-color, color; +} + +.expressive-code .copy button:hover, +.expressive-code .copy button:focus-visible, +.expressive-code .copy:has(.feedback.show) button { + background: rgba(255, 255, 255, 0.08); + color: var(--sl-color-white); + opacity: 1; +} + +:root[data-theme="light"] .expressive-code .copy button:hover, +:root[data-theme="light"] .expressive-code .copy button:focus-visible, +:root[data-theme="light"] .expressive-code .copy:has(.feedback.show) button { + background: rgba(7, 23, 51, 0.08); + color: #071733; +} + +.expressive-code .copy button:focus-visible { + outline: 2px solid rgba(120, 168, 255, 0.55); + outline-offset: 2px; +} + +.expressive-code .copy button div, +.expressive-code .copy button::before { + display: none; +} + +.expressive-code .copy button::after { + inset: auto; + top: 50%; + left: 50%; + width: 0.86rem; + height: 0.86rem; + margin: 0; + background-color: currentColor; + -webkit-mask-image: var(--chart-kit-code-copy-icon); + mask-image: var(--chart-kit-code-copy-icon); + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; + transform: translate(-50%, -50%); +} + +.expressive-code .copy:has(.feedback.show) button::after { + -webkit-mask-image: var(--chart-kit-code-check-icon); + mask-image: var(--chart-kit-code-check-icon); +} + +.expressive-code .copy .feedback { + position: absolute; + overflow: hidden; + width: 1px; + height: 1px; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +@media (hover: hover) { + .expressive-code .copy button { + opacity: 0; + } + + .expressive-code .frame:hover .copy button:not(:hover), + .expressive-code .frame:focus-within .copy button:not(:hover), + .expressive-code .copy:has(.feedback.show) button:not(:hover) { + opacity: 0.82; + } + + .expressive-code .copy button:hover, + .expressive-code .copy button:focus-visible { + opacity: 1; + } +} + +chart-kit-preview { + display: block; + margin-block: 1.25rem 2rem; +} + +chart-kit-preview .chart-kit-preview-fallback, +.chart-kit-live-preview__frame { + display: block; + overflow: hidden; + border: 1px solid var(--sl-color-hairline); + border-radius: 0.9rem; + background: #10131a; + padding: 1.15rem; +} + +:root[data-theme="light"] chart-kit-preview .chart-kit-preview-fallback, +:root[data-theme="light"] .chart-kit-live-preview__frame { + background: #ffffff; +} + +.chart-kit-live-preview__pro-link { + display: inline-flex; + width: fit-content; + align-items: center; + justify-content: center; + margin-top: 1rem; + border: 1px solid rgba(216, 230, 255, 0.18); + border-radius: 999px; + background: rgba(216, 230, 255, 0.1); + color: #d8e6ff; + font-size: 0.78rem; + font-weight: 750; + line-height: 1; + padding: 0.55rem 0.8rem; + text-decoration: none; +} + +.chart-kit-live-preview__pro-link:hover, +.chart-kit-live-preview__pro-link:focus-visible { + border-color: rgba(216, 230, 255, 0.32); + background: rgba(216, 230, 255, 0.16); + color: #ffffff; +} + +.chart-kit-live-preview__pro-link:focus-visible { + outline: 2px solid rgba(120, 168, 255, 0.55); + outline-offset: 2px; +} + +:root[data-theme="light"] .chart-kit-live-preview__pro-link { + border-color: rgba(15, 58, 120, 0.18); + background: rgba(15, 58, 120, 0.08); + color: #0f3a78; +} + +:root[data-theme="light"] .chart-kit-live-preview__pro-link:hover, +:root[data-theme="light"] .chart-kit-live-preview__pro-link:focus-visible { + border-color: rgba(15, 58, 120, 0.3); + background: rgba(15, 58, 120, 0.12); + color: #071733; +} + +.sidebar-content + a:is( + [href$="/charts/candlebar/"], + [href$="/charts/radar/"], + [href$="/charts/combo/"] + )::after { + flex: 0 0 auto; + margin-inline-start: auto; + border: 1px solid rgba(216, 230, 255, 0.18); + border-radius: 999px; + background: rgba(216, 230, 255, 0.09); + color: #d8e6ff; + content: "Pro"; + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0; + line-height: 1; + padding: 0.18rem 0.38rem; + text-transform: uppercase; +} + +:root[data-theme="light"] + .sidebar-content + a:is( + [href$="/charts/candlebar/"], + [href$="/charts/radar/"], + [href$="/charts/combo/"] + )::after { + border-color: rgba(15, 58, 120, 0.18); + background: rgba(15, 58, 120, 0.08); + color: #0f3a78; +} + +chart-kit-playground { + display: block; + margin-block: 1.25rem 2rem; +} + +chart-kit-playground .chart-kit-preview-fallback { + overflow: hidden; + border: 1px solid var(--sl-color-hairline); + border-radius: 0.9rem; + background: #11141c; +} + +:root[data-theme="light"] chart-kit-playground .chart-kit-preview-fallback { + background: #f8fbff; +} + +chart-kit-playground .chart-kit-preview-fallback pre { + margin: 0; + overflow: auto; + color: #e8edf8; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.79rem; + line-height: 1.65; + padding: 1rem; + tab-size: 2; +} + +:root[data-theme="light"] chart-kit-playground .chart-kit-preview-fallback pre { + color: #071733; +} + +.chart-kit-playground__frame { + overflow: hidden; + border: 1px solid var(--sl-color-hairline); + border-radius: 0.9rem; + background: #10131a; +} + +.chart-kit-playground__frame[data-resizing="true"] { + cursor: col-resize; + user-select: none; +} + +.chart-kit-playground__frame[data-resizing="true"] * { + cursor: col-resize !important; +} + +:root[data-theme="light"] .chart-kit-playground__frame { + background: #ffffff; +} + +.chart-kit-playground__grid { + display: grid; + grid-template-columns: + minmax(18rem, var(--chart-kit-editor-size, 50%)) 1px + minmax(18rem, 1fr); + min-height: 28rem; +} + +.chart-kit-playground__frame .chart-kit-playground__editor-pane, +.chart-kit-playground__frame .chart-kit-playground__preview-pane { + display: flex; + flex-direction: column; + margin: 0; + min-width: 0; +} + +.chart-kit-playground__editor-pane { + background: #11141c; +} + +:root[data-theme="light"] .chart-kit-playground__editor-pane { + background: #f8fbff; +} + +.chart-kit-playground__preview-pane { + min-height: 28rem; + background: #0f1218; +} + +:root[data-theme="light"] .chart-kit-playground__preview-pane { + background: #ffffff; +} + +.chart-kit-playground__resize-handle { + position: relative; + z-index: 2; + display: block; + width: 1px; + min-width: 1px; + align-self: stretch; + border: 0; + background: transparent; + cursor: col-resize; + padding: 0; +} + +.chart-kit-playground__resize-handle::before { + position: absolute; + z-index: 1; + top: 0; + bottom: 0; + left: -0.45rem; + width: 0.9rem; + content: ""; +} + +.chart-kit-playground__resize-handle::after { + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + background: var(--sl-color-hairline); + content: ""; + transform: translateX(-50%); +} + +.chart-kit-playground__resize-handle:hover::after, +.chart-kit-playground__resize-handle:focus-visible::after, +.chart-kit-playground__frame[data-resizing="true"] + .chart-kit-playground__resize-handle::after { + width: 2px; + background: #7aa7ff; +} + +.chart-kit-playground__resize-handle:focus-visible { + outline: none; +} + +.chart-kit-playground__pane-header { + box-sizing: border-box; + display: flex; + flex: 0 0 2.75rem; + height: 2.75rem; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid var(--sl-color-hairline); + color: var(--sl-color-gray-2); + font-size: 0.78rem; + font-weight: 760; + letter-spacing: 0; + line-height: 1; + padding-inline: 0.95rem; +} + +.chart-kit-playground__editor-pane .chart-kit-playground__pane-header { + padding-right: 0.55rem; +} + +.chart-kit-playground__language-label { + display: inline-flex; + align-items: center; + gap: 0.45rem; + min-width: 0; +} + +.chart-kit-playground__ts-logo { + display: block; + width: 1.05rem; + height: 1.05rem; + flex: 0 0 auto; +} + +.chart-kit-playground__copy-button { + position: relative; + display: inline-flex; + width: 1.55rem; + height: 1.55rem; + align-items: center; + justify-content: center; + flex: 0 0 auto; + border: 1px solid transparent; + border-radius: 0.45rem; + background: transparent; + color: var(--sl-color-gray-3); + cursor: pointer; + padding: 0; +} + +.chart-kit-playground__copy-button:hover, +.chart-kit-playground__copy-button:focus-visible, +.chart-kit-playground__copy-button[data-copied="true"] { + border-color: transparent; + background: transparent; + color: var(--sl-color-white); +} + +:root[data-theme="light"] .chart-kit-playground__copy-button:hover, +:root[data-theme="light"] .chart-kit-playground__copy-button:focus-visible, +:root[data-theme="light"] + .chart-kit-playground__copy-button[data-copied="true"] { + background: transparent; + color: #071733; +} + +.chart-kit-playground__copy-button:focus-visible { + outline: 2px solid rgba(120, 168, 255, 0.55); + outline-offset: 2px; +} + +.chart-kit-playground__copy-icon { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.9; +} + +.chart-kit-playground__copy-tooltip { + position: absolute; + z-index: 4; + right: 0; + top: calc(100% + 0.5rem); + width: max-content; + max-width: 11rem; + border: 1px solid var(--sl-color-hairline); + border-radius: 0.45rem; + background: #161a23; + color: var(--sl-color-white); + font-size: 0.72rem; + font-weight: 650; + line-height: 1; + opacity: 0; + padding: 0.45rem 0.55rem; + pointer-events: none; + transform: translateY(-0.2rem); + transition: + opacity 0.12s ease, + transform 0.12s ease; + visibility: hidden; +} + +.chart-kit-playground__copy-tooltip::after { + position: absolute; + right: 0.65rem; + top: -0.3rem; + width: 0.5rem; + height: 0.5rem; + border-top: 1px solid var(--sl-color-hairline); + border-left: 1px solid var(--sl-color-hairline); + background: inherit; + content: ""; + transform: rotate(45deg); +} + +:root[data-theme="light"] .chart-kit-playground__copy-tooltip { + background: #ffffff; + color: #071733; +} + +.chart-kit-playground__copy-button:hover .chart-kit-playground__copy-tooltip, +.chart-kit-playground__copy-button:focus-visible + .chart-kit-playground__copy-tooltip, +.chart-kit-playground__copy-button[data-copied="true"] + .chart-kit-playground__copy-tooltip { + opacity: 1; + transform: translateY(0); + visibility: visible; +} + +.chart-kit-playground__preview-title { + overflow: hidden; + color: var(--sl-color-gray-3); + font-weight: 680; + letter-spacing: 0; + text-overflow: ellipsis; + text-transform: none; + white-space: nowrap; +} + +.chart-kit-playground__editor { + min-height: 0; + flex: 1 1 auto; + color: #e8edf8; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.79rem; + line-height: 1.65; + overflow: hidden; +} + +:root[data-theme="light"] .chart-kit-playground__editor { + color: #071733; +} + +.chart-kit-playground__editor pre { + border: 0 !important; + border-radius: 0 !important; + min-height: 25.45rem; + max-height: 34rem; + overflow: auto; + background: transparent !important; + caret-color: currentColor; + color: inherit; + outline: none; + padding: 1rem !important; + scrollbar-color: rgba(122, 167, 255, 0.42) transparent; + scrollbar-width: thin; + tab-size: 2; + white-space: pre; +} + +:root[data-theme="light"] .chart-kit-playground__editor pre { + scrollbar-color: rgba(37, 99, 235, 0.34) transparent; +} + +.chart-kit-playground__editor pre::-webkit-scrollbar { + width: 0.55rem; + height: 0.55rem; +} + +.chart-kit-playground__editor pre::-webkit-scrollbar-track, +.chart-kit-playground__editor pre::-webkit-scrollbar-corner { + background: transparent; +} + +.chart-kit-playground__editor pre::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 999px; + background-color: rgba(122, 167, 255, 0.36); + background-clip: content-box; +} + +.chart-kit-playground__editor pre::-webkit-scrollbar-thumb:hover { + background-color: rgba(122, 167, 255, 0.56); +} + +:root[data-theme="light"] + .chart-kit-playground__editor + pre::-webkit-scrollbar-thumb { + background-color: rgba(37, 99, 235, 0.28); +} + +:root[data-theme="light"] + .chart-kit-playground__editor + pre::-webkit-scrollbar-thumb:hover { + background-color: rgba(37, 99, 235, 0.46); +} + +.chart-kit-playground__editor pre:focus-visible { + outline: 2px solid rgba(120, 168, 255, 0.55); + outline-offset: -2px; +} + +:root[data-theme="light"] .chart-kit-playground__editor pre:focus-visible { + outline-color: rgba(37, 99, 235, 0.45); +} + +.chart-kit-playground__preview-surface { + display: flex; + min-height: 22rem; + flex: 1; + align-items: center; + justify-content: center; + overflow: auto; + padding: 1.25rem; +} + +.chart-kit-playground__error { + margin: 0; + border-top: 1px solid rgba(220, 38, 38, 0.35); + background: rgba(220, 38, 38, 0.1); + color: #fecaca; + font-size: 0.78rem; + line-height: 1.5; + padding: 0.85rem 1rem; + white-space: pre-wrap; +} + +:root[data-theme="light"] .chart-kit-playground__error { + color: #991b1b; +} + +.right-sidebar { + border-color: var(--sl-color-hairline); +} + +.right-sidebar-panel { + color: var(--sl-color-gray-3); +} + +.right-sidebar-panel h2 { + font-size: 0.9rem; +} + +.right-sidebar-panel a { + border-radius: 0.5rem; + padding: 0.22rem 0.4rem; +} + +.right-sidebar-panel ul ul { + margin-block: 0.05rem 0.25rem; +} + +.right-sidebar-panel ul ul a { + display: flex; + align-items: stretch; + color: var(--sl-color-gray-3); + font-size: 0.78rem; + font-weight: 600; + padding-inline-start: 1.2rem; +} + +.right-sidebar-panel ul ul a::before { + align-self: stretch; + width: 1px; + flex: 0 0 auto; + margin-block: 0.18rem; + margin-inline-end: 0.45rem; + background: var(--sl-color-hairline); + content: ""; +} + +.right-sidebar-panel a:hover { + background: rgba(255, 255, 255, 0.045); +} + +:root[data-theme="light"] .right-sidebar-panel a:hover { + background: rgba(7, 23, 51, 0.045); +} + +.pagination-links a { + border-color: var(--sl-color-hairline); + border-radius: 0.9rem; + background: rgba(255, 255, 255, 0.035); + font-size: 0.68rem; + font-weight: 650; + line-height: 1.35; + padding: 0.85rem; +} + +:root[data-theme="light"] .pagination-links a { + background: #ffffff; +} + +.pagination-links .link-title { + display: inline-block; + margin-top: 0.18rem; + font-size: 0.86rem; + font-weight: 700; + line-height: 1.25; +} + +.sl-markdown-content blockquote:not(:where(.not-content *)), +.sl-markdown-content details:not(:where(.not-content *)) { + border-color: var(--sl-color-hairline); +} + +@media (max-width: 49.99rem) { + :root { + --sl-content-pad-x: 1.25rem; + } + + .mobile-preferences .chartkit-chart-theme-control { + width: 100%; + justify-content: space-between; + margin-inline-end: 0; + } + + .mobile-preferences .chartkit-chart-theme-control__select { + min-width: min(11rem, 58vw); + } + + .mobile-preferences starlight-theme-select { + margin-inline-start: auto; + } + + .site-title { + font-size: 0.86rem; + } + + .site-title::before { + width: 1.85rem; + height: 1.85rem; + } + + h1#_top, + .sl-markdown-content h1:not(:where(.not-content *)) { + font-size: 1.9rem; + } + + .sl-markdown-content h2:not(:where(.not-content *)) { + font-size: 1.42rem; + } + + .chart-kit-playground__grid { + grid-template-columns: 1fr; + } + + .chart-kit-playground__editor-pane { + border-bottom: 1px solid var(--sl-color-hairline); + } + + .chart-kit-playground__resize-handle { + display: none; + } + + .chart-kit-playground__editor pre { + min-height: 20rem; + max-height: 24rem; + } + + .chart-kit-playground__preview-pane { + min-height: 23rem; + } +} diff --git a/apps/site/tsconfig.json b/apps/site/tsconfig.json index 9a2f92aa..f9baeca4 100644 --- a/apps/site/tsconfig.json +++ b/apps/site/tsconfig.json @@ -4,6 +4,7 @@ "baseUrl": ".", "paths": { "@chart-kit/core": ["../../packages/core/src/index.ts"], + "@chart-kit/pro": ["src/previews/proStub.tsx"], "@chart-kit/svg-renderer": ["../../packages/svg-renderer/src/index.ts"], "react-native-chart-kit/v2": ["../../packages/react-native/src/index.ts"] }, diff --git a/docs/README.md b/docs/README.md index 6cba3a10..8a1f9c3e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,13 +7,14 @@ description: Start here for the React Native Chart Kit v2 documentation. These docs describe the public React Native Chart Kit package: the legacy-compatible root API and the free modern v2 API. `react-native-chart-kit` -is the only public npm install path. The `@chart-kit/*` workspaces are private -repo-internal packages used to develop and build the modern implementation. -Modern v2 examples import from `react-native-chart-kit/v2`. +is the public npm install path for free charts. Modern v2 examples import from +`react-native-chart-kit/v2`. Pro chart examples import from `@chart-kit/pro`, +which requires a Pro license. ## Getting Started - [Quickstart](getting-started/installation.md) +- [Contributing](getting-started/contributing.md) ## Migration @@ -22,12 +23,20 @@ Modern v2 examples import from `react-native-chart-kit/v2`. ## Charts -- [Line and area](charts/line-and-area.md) +- [Line](charts/line.md) +- [Area](charts/area.md) - [Bar](charts/bar.md) -- [Pie and donut](charts/pie-and-donut.md) +- [Pie](charts/pie.md) +- [Donut](charts/donut.md) - [Progress](charts/progress.md) - [Contribution heatmap](charts/contribution-heatmap.md) +## Pro Charts + +- [Candlebar](charts/candlebar.md) +- [Radar](charts/radar.md) +- [Combo](charts/combo.md) + ## Guides - [Themes](charts/themes.md) @@ -37,14 +46,3 @@ Modern v2 examples import from `react-native-chart-kit/v2`. ## Recipes - [Production recipes](recipes/README.md) - -## Local Review - -Type-check the React Native CLI example surface when changing package imports or peer setup: - -```sh -npm run example:rn-cli:typecheck -``` - -The Expo preview app lives in the private `chart-kit-pro` repository because it -combines free and Pro chart examples. diff --git a/docs/charts/area.md b/docs/charts/area.md new file mode 100644 index 00000000..42857b08 --- /dev/null +++ b/docs/charts/area.md @@ -0,0 +1,197 @@ +--- +title: Area Chart +description: Build filled trend charts with AreaChart or LineChart area mode. +--- + +# Area Chart + +`AreaChart` is the dedicated v2 surface for filled trend charts. It shares the +same data model and interaction primitives as `LineChart`, but defaults to an +area fill. + +## Basic Area + +```tsx +import { AreaChart } from "react-native-chart-kit/v2"; + +const data = [ + { date: "Jan 01", price: 72 }, + { date: "Jan 03", price: 138 }, + { date: "Jan 08", price: 91 }, + { date: "Jan 15", price: 166 }, + { date: "Jan 24", price: 118 }, + { date: "Feb 01", price: 202 } +]; + +export function PipelineChart() { + return ( + + ); +} +``` + +::chart-preview{id="line-area"} + +## Area Fill + +Use `areaFill` to tune fill opacity. You can also pass `area` to `LineChart` +when a product uses line and area variants from the same component wrapper. + +```tsx +const data = [ + { date: "Jan", price: 42 }, + { date: "Feb", price: 96 }, + { date: "Mar", price: 58 }, + { date: "Apr", price: 132 }, + { date: "May", price: 84 }, + { date: "Jun", price: 158 } +]; + +; +``` + +Use `areaFill` on the chart for a shared fill style, or per series when each +series needs its own colors or opacity. + +## Threshold Area + +Threshold coloring clips the rendered path and area fill above or below a y +value. Raw points are unchanged. + +```tsx +const data = [ + { month: "Jan", attainment: 62 }, + { month: "Feb", attainment: 138 }, + { month: "Mar", attainment: 74 }, + { month: "Apr", attainment: 151 }, + { month: "May", attainment: 89 }, + { month: "Jun", attainment: 122 }, + { month: "Jul", attainment: 55 }, + { month: "Aug", attainment: 164 }, + { month: "Sep", attainment: 96 }, + { month: "Oct", attainment: 145 }, + { month: "Nov", attainment: 78 }, + { month: "Dec", attainment: 172 } +]; + +; +``` + +## Dense Area Charts + +For long time series, use the same viewport, scrolling, and decimation options +as `LineChart`. + +```tsx + +``` + +## Props + +### AreaChart + +`AreaChart` accepts the same prop surface as `LineChart` and renders with area fill enabled by default. + +| Prop | Type | Description | +| ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `data` | `TData[]` | Object-row source data for the chart. | +| `xKey` | `keyof TData` | Row key used for the x-axis value. | +| `yKey` | `keyof TData` | Single row key used for y values when `series` or `yKeys` is not provided. | +| `yKeys` | `Array` | Multiple row keys rendered as separate series with default styling. | +| `series` | `LineChartSeries[]` | Full per-series configuration, including labels, colors, stroke, dots, thresholds, and area fill. | +| `width` | `number` | Outer chart width in pixels. | +| `height` | `number` | Outer chart height in pixels. | +| `theme` | `ChartKitThemeMode` or `CartesianChartTheme` | Theme mode or inline theme tokens for this chart. | +| `preset` | `CartesianChartPresetValue` | Built-in or registered preset name used to seed chart colors and typography. | +| `scrollable` | `boolean` | Enables a horizontal scroll viewport for long data sets. | +| `visiblePoints` | `number` | Number of points visible in the viewport when `scrollable` is enabled. | +| `initialIndex` | `ChartViewportInitialIndex` | Initial scroll/window position, such as `"start"` or `"end"`. | +| `viewport` | `LineChartViewportConfig` | Controlled visible data window for scroll, pan, zoom, or range selector flows. | +| `onViewportChange` | `(event) => void` | Called when viewport state changes from the main plot or range selector. | +| `viewportInteraction` | `boolean` or `LineChartViewportInteractionConfig` | Enables and configures one-finger pan and pinch zoom for the viewport. | +| `rangeSelector` | `boolean` or `LineChartRangeSelectorConfig` | Adds a mini-chart range selector below the main plot. | +| `decimation` | `false`, `"auto"`, `number`, or `LineChartDecimationConfig` | Controls rendered path simplification for dense series. | +| `curve` | `LineCurve` | Curve interpolation used for line and area paths. | +| `connectNulls` | `boolean` | Connects defined points across `null` or missing y values. | +| `area` | `boolean` | Inherited from `LineChartProps`; `AreaChart` renders with area fill enabled. | +| `areaFill` | `LineChartAreaFillConfig` | Shared area fill opacity, gradient, or color configuration. | +| `showDots` | `boolean` | Shows or hides all point markers. | +| `dots` | `boolean` or `LineChartDotConfig` | Configures default point marker visibility, size, shape, and color. | +| `renderDot` | `(props) => ReactNode` | Custom renderer for ordinary point markers. | +| `selectedIndex` | `number` | Controlled selected data index. | +| `defaultSelectedIndex` | `number` | Initial uncontrolled selected data index. | +| `activeDot` | `boolean` or `LineChartDotConfig` | Configures the marker shown for the selected point. | +| `renderActiveDot` | `(props) => ReactNode` | Custom renderer for the selected point marker. | +| `interaction` | `LineChartInteraction` | Selection/scrub interaction mode and callbacks. | +| `crosshair` | `boolean` or `LineChartCrosshairConfig` | Shows and configures the selected-point crosshair. | +| `renderCrosshair` | `(props) => ReactNode` | Custom renderer for the crosshair. | +| `tooltip` | `boolean` or `LineChartTooltipConfig` | Shows and configures selected-point tooltip content and placement. | +| `renderTooltip` | `(props) => ReactNode` | Custom renderer for selected-point tooltip content. | +| `referenceLines` | `LineChartReferenceLineConfig[]` | Horizontal reference lines drawn across the plot. | +| `referenceBands` | `LineChartReferenceBandConfig[]` | Horizontal reference bands drawn behind the series. | +| `showHorizontalGridLines` | `boolean` | Shows or hides horizontal grid lines. | +| `showVerticalGridLines` | `boolean` | Shows or hides vertical grid lines. | +| `legend` | `boolean` or `LineChartLegendConfig` | Shows and configures the chart legend. | +| `labelStrategy` | `LineChartLabelStrategy` | Controls x-axis label density and layout. | +| `labelRotation` | `number` | Rotation angle for x-axis labels when using rotated labels. | +| `labelMinGap` | `number` | Minimum gap used by automatic x-axis label skipping. | +| `edgeLabelPolicy` | `LineChartEdgeLabelPolicy` | Controls how first and last x-axis labels are shifted, hidden, or shown. | +| `yDomain` | `NumericDomainInput` | Overrides or constrains the computed y-axis domain. | +| `yAxisLabelWidth` | `LineChartYAxisLabelWidth` | Fixed, automatic, or stable width for y-axis labels. | +| `axisLabelAnimation` | `boolean` or `LineChartAxisLabelAnimationConfig` | Animates y-axis label changes during viewport updates. | +| `formatXLabel` | `(value, index) => string` | Formats x-axis labels and selected x labels. | +| `formatYLabel` | `(value) => string` | Formats y-axis labels, selected values, and tooltip values. | +| `renderer` | `LineChartRenderer` | Renderer implementation used for SVG-compatible primitives. | +| `id` | `string` | Stable chart id used for internal ids and coordinated selection scope. | +| `debugLayout` | `boolean` | Renders layout debug rectangles in development. | +| `onLayoutDebug` | `(model) => void` | Receives computed layout debug geometry. | +| `accessibilityLabel` | `string` | Overrides the generated accessible chart summary. | +| `testID` | `string` | Test identifier applied to the chart surface. | diff --git a/docs/charts/bar.md b/docs/charts/bar.md index 79ceb5ce..a4d64e88 100644 --- a/docs/charts/bar.md +++ b/docs/charts/bar.md @@ -17,9 +17,12 @@ All examples on this page are part of the public free v2 API. import { BarChart } from "react-native-chart-kit/v2"; const data = [ - { month: "Jan", signups: 42 }, - { month: "Feb", signups: 48 }, - { month: "Mar", signups: 54 } + { month: "Jan", signups: 180 }, + { month: "Feb", signups: 520 }, + { month: "Mar", signups: 260 }, + { month: "Apr", signups: 740 }, + { month: "May", signups: 390 }, + { month: "Jun", signups: 860 } ]; export function SignupsChart() { @@ -42,6 +45,15 @@ export function SignupsChart() { Use `series` for multiple bars per x value. The chart shows a bottom legend by default when there is more than one series. ```tsx +const data = [ + { month: "Jan", organic: 28, paid: 62 }, + { month: "Feb", organic: 74, paid: 34 }, + { month: "Mar", organic: 39, paid: 88 }, + { month: "Apr", organic: 96, paid: 41 }, + { month: "May", organic: 54, paid: 103 }, + { month: "Jun", organic: 118, paid: 58 } +]; + +/>; ``` ::chart-preview{id="bar-grouped"} @@ -70,6 +82,15 @@ Useful grouped-bar props: Negative values render below the zero baseline. Keep `yDomain` on its default include-zero behavior unless you intentionally want a cropped baseline. ```tsx +const profit = [ + { month: "Jan", profit: 38 }, + { month: "Feb", profit: -28 }, + { month: "Mar", profit: 64 }, + { month: "Apr", profit: -42 }, + { month: "May", profit: 81 }, + { month: "Jun", profit: -18 } +]; + (value < 0 ? `-$${Math.abs(value)}k` : `$${value}k`)} width={360} height={250} -/> +/>; ``` ::chart-preview{id="bar-negative"} @@ -89,6 +110,14 @@ Negative values render below the zero baseline. Keep `yDomain` on its default in Set `orientation="horizontal"` when category labels are easier to scan down the left axis. ```tsx +const supportVolume = [ + { channel: "Chat", tickets: 95 }, + { channel: "Email", tickets: 37 }, + { channel: "Phone", tickets: 68 }, + { channel: "Social", tickets: 24 }, + { channel: "Community", tickets: 113 } +]; + +/>; ``` ::chart-preview{id="bar-horizontal"} @@ -109,6 +138,15 @@ Horizontal bars support grouped, stacked, 100% stacked, and negative values thro Set `mode="stacked"` to stack series by row. Positive and negative stacks are tracked separately from the zero baseline. ```tsx +const data = [ + { month: "Jan", newCustomers: 180, expansion: 60 }, + { month: "Feb", newCustomers: 520, expansion: 210 }, + { month: "Mar", newCustomers: 260, expansion: 120 }, + { month: "Apr", newCustomers: 740, expansion: 330 }, + { month: "May", newCustomers: 390, expansion: 170 }, + { month: "Jun", newCustomers: 860, expansion: 410 } +]; + +/>; ``` ## 100% Stacked Bars @@ -127,18 +165,28 @@ Set `mode="stacked"` to stack series by row. Positive and negative stacks are tr Set `mode="stacked100"` for percentage composition. The original values are preserved in the bar model while rendered heights are normalized to row totals. ```tsx +const platformShare = [ + { month: "Jan", ios: 62, android: 25, web: 13 }, + { month: "Feb", ios: 38, android: 47, web: 15 }, + { month: "Mar", ios: 55, android: 21, web: 24 }, + { month: "Apr", ios: 29, android: 58, web: 13 }, + { month: "May", ios: 68, android: 19, web: 13 }, + { month: "Jun", ios: 44, android: 31, web: 25 } +]; + `${value}%`} width={360} height={250} -/> +/>; ``` ::chart-preview{id="bar-stacked"} @@ -180,6 +228,15 @@ The facade maps `labels`, `legend`, `data`, `barColors`, `hideLegend`, `percenti Bar selection is opt-in. Use `interaction="tap"` for the simplest behavior, or pass an object when you need callbacks or outside-press dismissal. ```tsx +const data = [ + { month: "Jan", organic: 28, paid: 62 }, + { month: "Feb", organic: 74, paid: 34 }, + { month: "Mar", organic: 39, paid: 88 }, + { month: "Apr", organic: 96, paid: 41 }, + { month: "May", organic: 54, paid: 103 }, + { month: "Jun", organic: 118, paid: 58 } +]; + +/>; ``` The public select event includes `dataIndex`, `seriesKey`, `seriesLabel`, `value`, `formattedValue`, `x`, `xLabel`, `color`, `position`, and the original `raw` row. @@ -214,6 +271,27 @@ Tooltip positioning uses `anchor: "bar"` and `placement: "auto"` by default. Use Use `scrollable`, `visiblePoints`, and `initialIndex` for long categorical bar charts. For bars, `visiblePoints` maps to visible bar bands. ```tsx +const weeklySpend = [ + { week: "W1", spend: 18 }, + { week: "W2", spend: 52 }, + { week: "W3", spend: 26 }, + { week: "W4", spend: 74 }, + { week: "W5", spend: 31 }, + { week: "W6", spend: 88 }, + { week: "W7", spend: 43 }, + { week: "W8", spend: 96 }, + { week: "W9", spend: 39 }, + { week: "W10", spend: 108 }, + { week: "W11", spend: 57 }, + { week: "W12", spend: 121 }, + { week: "W13", spend: 44 }, + { week: "W14", spend: 132 }, + { week: "W15", spend: 63 }, + { week: "W16", spend: 118 }, + { week: "W17", spend: 71 }, + { week: "W18", spend: 146 } +]; + `$${value}k`} width={360} height={250} -/> +/>; ``` The y-axis labels stay pinned while the bars and x-axis labels scroll horizontally. @@ -236,6 +314,17 @@ Use `renderBar` when bars need product-specific styling while keeping the built- ```tsx import { Rect } from "react-native-svg"; +const weeklySpend = [ + { week: "W1", spend: 18 }, + { week: "W2", spend: 52 }, + { week: "W3", spend: 26 }, + { week: "W4", spend: 74 }, + { week: "W5", spend: 31 }, + { week: "W6", spend: 88 }, + { week: "W7", spend: 43 }, + { week: "W8", spend: 96 } +]; + ` | Multiple row keys rendered as grouped or stacked series with default styling. | +| `series` | `BarChartSeries[]` | Full per-series configuration, including y key, label, key, and color. | +| `width` | `number` | Outer chart width in pixels. | +| `height` | `number` | Outer chart height in pixels. | +| `theme` | `ChartKitThemeMode` or `CartesianChartTheme` | Theme mode or inline theme tokens for this chart. | +| `preset` | `CartesianChartPresetValue` | Built-in or registered preset name used to seed chart colors and typography. | +| `scrollable` | `boolean` | Enables a horizontal scroll viewport for long bar sets. | +| `visiblePoints` | `number` | Number of x values visible in the viewport when `scrollable` is enabled. | +| `initialIndex` | `ChartViewportInitialIndex` | Initial scroll position, such as `"start"` or `"end"`. | +| `orientation` | `BarChartOrientation` | Renders vertical or horizontal bars. | +| `mode` | `BarChartMode` | Chooses grouped, stacked, or 100% stacked layout. | +| `yDomain` | `NumericDomainInput` | Overrides or constrains the computed value-axis domain. | +| `barRadius` | `number` | Corner radius applied to rendered bars. | +| `barWidthRatio` | `number` | Portion of each category slot occupied by bars. | +| `barGapRatio` | `number` | Relative gap between grouped bars. | +| `showValuesOnTopOfBars` | `boolean` | Shows formatted value labels at bar ends. | +| `showHorizontalGridLines` | `boolean` | Shows or hides horizontal grid lines. | +| `showXAxisLabels` | `boolean` | Shows or hides x-axis labels. | +| `showYAxisLabels` | `boolean` | Shows or hides y-axis labels. | +| `yTickCount` | `number` | Number of ticks used for value-axis labels and grid lines. | +| `legend` | `boolean` | Shows or hides the bottom legend. | +| `interaction` | `BarChartInteraction` | Tap selection mode and callbacks. | +| `selectedBar` | `BarChartSelectedBar` | Controlled selected bar by data index and series key. | +| `defaultSelectedBar` | `BarChartSelectedBar` | Initial uncontrolled selected bar. | +| `selectionAnimation` | `boolean` or `BarChartSelectionAnimationConfig` | Enables and configures selected-bar animation. | +| `tooltip` | `boolean` or `BarChartTooltipConfig` | Shows and configures selected-bar tooltip content and placement. | +| `renderBar` | `(props) => ReactNode` | Custom renderer for each bar while preserving layout and hit testing. | +| `renderer` | `BarChartRenderer` | Renderer implementation used for SVG-compatible primitives. | +| `labelStrategy` | `BarChartLabelStrategy` | Controls x-axis label visibility. | +| `formatXLabel` | `(value, index) => string` | Formats x-axis labels and selected x labels. | +| `formatYLabel` | `(value) => string` | Formats y-axis labels, value labels, and tooltip values. | +| `id` | `string` | Stable chart id used for coordinated selection scope. | +| `accessibilityLabel` | `string` | Overrides the generated accessible chart summary. | +| `testID` | `string` | Test identifier applied to the chart surface. | + +### StackedBarChart + +`StackedBarChart` is the v2 compatibility facade for legacy stacked-bar data. + +| Prop | Type | Description | +| -------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------- | +| `data` | `StackedBarChartLegacyData` | Legacy stacked-bar data with `labels`, `legend`, `data`, and `barColors`. | +| `width` | `number` | Outer chart width in pixels. | +| `height` | `number` | Outer chart height in pixels. | +| `chartConfig` | `StackedBarChartLegacyConfig` | Legacy color, label, decimal, bar, and grid configuration. | +| `hideLegend` | `boolean` | Hides the generated legend when true. | +| `style` | `StyleProp` | Style applied to the compatibility wrapper. | +| `barPercentage` | `number` | Overrides the bar width ratio from `chartConfig`. | +| `decimalPlaces` | `number` | Controls formatted value precision. | +| `withVerticalLabels` | `boolean` | Shows or hides x-axis labels. | +| `withHorizontalLabels` | `boolean` | Shows or hides y-axis labels. | +| `withInnerLines` | `boolean` | Shows or hides inner grid lines. | +| `segments` | `number` | Controls y-axis tick density. | +| `percentile` | `boolean` | Renders the stack as 100% stacked values. | +| `fromZero` | `boolean` | Forces the value domain to include zero. | +| `yAxisLabel` | `string` | Prefix added to formatted y-axis values. | +| `yAxisSuffix` | `string` | Suffix added to formatted y-axis values. | +| `verticalLabelsHeightPercentage` | `number` | Legacy compatibility prop accepted by the facade. | +| `formatYLabel` | `(yLabel) => string` | Formats y-axis labels after decimal formatting. | +| `theme` | `ChartKitThemeMode` or `CartesianChartTheme` | Theme mode or inline theme tokens for this chart. | +| `preset` | `CartesianChartPresetValue` | Built-in or registered preset name used to seed chart colors and typography. | +| `accessibilityLabel` | `string` | Overrides the generated accessible chart summary. | +| `testID` | `string` | Test identifier applied to the chart surface. | diff --git a/docs/charts/candlebar.md b/docs/charts/candlebar.md new file mode 100644 index 00000000..6405eef9 --- /dev/null +++ b/docs/charts/candlebar.md @@ -0,0 +1,79 @@ +--- +title: Candlebar Chart +description: Build OHLC and volume charts for trading, crypto, and market data screens with Chart Kit Pro. +--- + +# Candlebar Chart + +`CandlebarChart` is a Pro chart for OHLC market data. It pairs candlestick +bodies, high/low wicks, volume context, and selection-ready hit targets in one +mobile chart surface. + +This chart is available in Chart Kit Pro. + +## Basic Candlebar + +```tsx +import { CandlebarChart } from "@chart-kit/pro"; + +const candles = [ + { date: "09:30", open: 184, high: 196, low: 179, close: 191, volume: 42 }, + { date: "10:00", open: 191, high: 208, low: 188, close: 202, volume: 68 }, + { date: "10:30", open: 202, high: 206, low: 185, close: 189, volume: 74 }, + { date: "11:00", open: 189, high: 215, low: 186, close: 211, volume: 95 }, + { date: "11:30", open: 211, high: 226, low: 204, close: 219, volume: 88 }, + { date: "12:00", open: 219, high: 221, low: 198, close: 204, volume: 81 }, + { date: "12:30", open: 204, high: 232, low: 201, close: 228, volume: 118 }, + { date: "13:00", open: 228, high: 236, low: 214, close: 217, volume: 101 } +]; + +export function TradingSession() { + return ( + + ); +} +``` + +::chart-preview{id="pro-candlebar"} + +## Product Use Cases + +Use Candlebar charts for stocks, crypto, commodities, FX, embedded broker +flows, portfolio analytics, and any screen where users need to inspect price +movement without leaving the mobile app. + +## Install + +Pro charts are distributed separately from the free package: + +```sh +npm install react-native-chart-kit @chart-kit/pro react-native-svg +``` + +Access to `@chart-kit/pro` requires a Pro license. + +## Props + +| Prop | Type | Description | +| ---------------------- | ------------- | ---------------------------------------------------------- | +| `data` | `TData[]` | Object-row OHLC source data. | +| `dateKey` | `keyof TData` | Row key used for x-axis labels and selected candle labels. | +| `openKey` | `keyof TData` | Row key used for opening values. | +| `highKey` | `keyof TData` | Row key used for high wick values. | +| `lowKey` | `keyof TData` | Row key used for low wick values. | +| `closeKey` | `keyof TData` | Row key used for closing values. | +| `volumeKey` | `keyof TData` | Optional row key used for volume bars. | +| `defaultSelectedIndex` | `number` | Initial selected candle index. | +| `width` | `number` | Outer chart width in pixels. | +| `height` | `number` | Outer chart height in pixels. | diff --git a/docs/charts/combo.md b/docs/charts/combo.md new file mode 100644 index 00000000..50797906 --- /dev/null +++ b/docs/charts/combo.md @@ -0,0 +1,73 @@ +--- +title: Combo Chart +description: Mix bars and lines on one coordinated mobile chart surface with Chart Kit Pro. +--- + +# Combo Chart + +`ComboChart` is a Pro chart for dashboards that need bars and lines on the same +axis. It is built for revenue operations, growth analytics, cohort performance, +and product health screens where related signals should be inspected together. + +This chart is available in Chart Kit Pro. + +## Basic Combo + +```tsx +import { ComboChart } from "@chart-kit/pro"; + +const data = [ + { month: "Jan", revenue: 420, forecast: 480, margin: 128 }, + { month: "Feb", revenue: 560, forecast: 530, margin: 168 }, + { month: "Mar", revenue: 490, forecast: 610, margin: 151 }, + { month: "Apr", revenue: 720, forecast: 690, margin: 214 }, + { month: "May", revenue: 640, forecast: 760, margin: 193 }, + { month: "Jun", revenue: 880, forecast: 840, margin: 276 }, + { month: "Jul", revenue: 790, forecast: 920, margin: 244 }, + { month: "Aug", revenue: 1040, forecast: 980, margin: 331 } +]; + +export function RevenueOperations() { + return ( + + ); +} +``` + +::chart-preview{id="pro-combo"} + +## Product Use Cases + +Use Combo charts for revenue vs forecast, spend vs acquisition, active users vs +conversion, inventory vs sell-through, and operational dashboards where one +metric explains another. + +## Install + +```sh +npm install react-native-chart-kit @chart-kit/pro react-native-svg +``` + +Access to `@chart-kit/pro` requires a Pro license. + +## Props + +| Prop | Type | Description | +| ---------------------- | -------------------- | -------------------------------------------- | +| `data` | `TData[]` | Object-row source data. | +| `xKey` | `keyof TData` | Row key used for x-axis labels. | +| `series` | `ComboChartSeries[]` | Mixed `bar` and `line` series configuration. | +| `defaultSelectedIndex` | `number` | Initial selected x value. | +| `width` | `number` | Outer chart width in pixels. | +| `height` | `number` | Outer chart height in pixels. | diff --git a/docs/charts/contribution-heatmap.md b/docs/charts/contribution-heatmap.md index 7bd98130..4ac803fb 100644 --- a/docs/charts/contribution-heatmap.md +++ b/docs/charts/contribution-heatmap.md @@ -10,13 +10,28 @@ description: Render calendar-style contribution heatmaps for activity and intens ```tsx import { ContributionGraph } from "react-native-chart-kit/v2"; +const endDate = "2026-05-03"; +const numDays = 154; +const values = Array.from({ length: numDays }, (_, index) => { + const end = new Date(`${endDate}T00:00:00.000Z`); + const date = new Date( + end.valueOf() - (numDays - 1 - index) * 24 * 60 * 60 * 1000 + ); + const weekday = date.getUTCDay(); + const cycle = (index * 7 + weekday * 3) % 17; + const launchWeekBoost = index > 110 && index < 126 ? 8 : 0; + const weekendDip = weekday === 0 || weekday === 6 ? -4 : 0; + + return { + date: date.toISOString().slice(0, 10), + count: Math.max(0, cycle + launchWeekBoost + weekendDip) + }; +}); + string` | Custom color resolver for each rendered cell. | +| `getMonthLabel` | `(monthIndex, date) => string` | Custom month label formatter. | +| `getWeekdayLabel` | `(dayIndex) => string` | Custom weekday label formatter. | +| `onDayPress` | `(event) => void` | Called when a day cell is pressed. | +| `renderer` | `ContributionGraphRenderer` | Renderer implementation used for SVG-compatible primitives. | +| `accessibilityLabel` | `string` | Overrides the generated accessible chart summary. | +| `testID` | `string` | Test identifier applied to the chart surface. | diff --git a/docs/charts/donut.md b/docs/charts/donut.md new file mode 100644 index 00000000..07c736ff --- /dev/null +++ b/docs/charts/donut.md @@ -0,0 +1,151 @@ +--- +title: Donut Chart +description: Render donut charts with center labels, legends, and tap selection. +--- + +# Donut Chart + +`DonutChart` uses the same normalized slice model as `PieChart`, with a default +inner radius and center-label support. + +## Basic Donut + +```tsx +import { DonutChart } from "react-native-chart-kit/v2"; + +const revenueMix = [ + { plan: "Enterprise", revenue: 680 }, + { plan: "Business", revenue: 420 }, + { plan: "Teams", revenue: 260 }, + { plan: "Starter", revenue: 140 } +]; + +; +``` + +::chart-preview{id="donut-basic"} + +## Current Scope + +The first v2 donut chart supports: + +- modern object-row data +- theme and preset colors +- bottom wrapped legend +- percentage labels in the legend +- donut center text +- rich custom center labels +- custom legend item rendering +- tap selection with active-slice highlighting +- zero and invalid slices without broken paths + +## Tap Selection + +Use `interaction="tap"` for uncontrolled selection, or pass `selectedIndex` with +`interaction.onSelect` for controlled product UI. + +```tsx +const revenueMix = [ + { plan: "Enterprise", revenue: 680 }, + { plan: "Business", revenue: 420 }, + { plan: "Teams", revenue: 260 }, + { plan: "Starter", revenue: 140 } +]; + +const [selectedIndex, setSelectedIndex] = useState(0); + + setSelectedIndex(event.index) + }} + centerLabel={revenueMix[selectedIndex]?.plan} + activeSlice={{ inactiveOpacity: 0.36, strokeWidth: 4 }} + width={360} + height={260} +/>; +``` + +## Custom Legend and Center Label + +Use `legend.renderItem` when the default compact legend is not enough. +`centerLabel` can return React content for multi-line KPI labels. + +```tsx +const retentionSegments = [ + { status: "Expanded annual contracts", accounts: 48 }, + { status: "Renewed monthly workspaces", accounts: 32 }, + { status: "At-risk accounts under review", accounts: 14 }, + { status: "Paused or dormant teams", accounts: 6 }, + { status: "Zero usage migrations", accounts: 0 } +]; + + ( + + {total} + accounts + + )} + legend={{ + maxItemWidth: "100%", + renderItem: ({ item, theme }) => ( + + {item.label} + {item.percentageLabel} + + ) + }} + width={360} + height={300} +/>; +``` + +## Props + +### DonutChart + +`DonutChart` accepts the same prop surface as `PieChart` and defaults `innerRadiusRatio` to `0.58`. + +| Prop | Type | Description | +| ---------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `data` | `TData[]` | Object-row source data for the chart. | +| `valueKey` | `keyof TData` | Row key used for slice values. | +| `labelKey` | `keyof TData` | Row key used for slice and legend labels. | +| `colorKey` | `keyof TData` | Row key used for per-slice colors. | +| `colors` | `string[]` | Fallback color palette used when `colorKey` is not provided. | +| `width` | `number` | Outer chart width in pixels. | +| `height` | `number` | Outer chart height in pixels. | +| `theme` | `"light"`, `"dark"`, `"system"`, or `CartesianChartTheme` | Theme mode or inline theme tokens for this chart. | +| `preset` | `CartesianChartPresetValue` | Built-in or registered preset name used to seed chart colors and typography. | +| `innerRadius` | `number` | Explicit inner radius in pixels. | +| `innerRadiusRatio` | `number` | Inner radius as a fraction of the computed outer radius. | +| `legend` | `boolean` or `PieChartLegendConfig` | Shows and configures the wrapped legend. | +| `arcLabels` | `boolean` or `PieChartArcLabelsConfig` | Shows and configures external arc labels and connector lines. | +| `selectedIndex` | `number` | Controlled selected slice index. | +| `defaultSelectedIndex` | `number` | Initial uncontrolled selected slice index. | +| `activeSlice` | `PieChartActiveSliceConfig` | Configures selected-slice stroke, opacity, offset, and scale. | +| `selectionAnimation` | `boolean` or `PieChartSelectionAnimationConfig` | Enables and configures selected-slice animation. | +| `interaction` | `PieChartInteraction` | Tap selection mode and callbacks. | +| `centerLabel` | `string`, `ReactNode`, or `(props) => ReactNode` | Content rendered in the donut center. | +| `renderer` | `PieChartRenderer` | Renderer implementation used for SVG-compatible primitives. | +| `accessibilityLabel` | `string` | Overrides the generated accessible chart summary. | +| `id` | `string` | Stable chart id used for coordinated selection scope. | +| `testID` | `string` | Test identifier applied to the chart surface. | +| `formatValue` | `(value) => string` | Formats raw slice values in labels and accessible output. | +| `formatPercentage` | `(percentage) => string` | Formats percentage labels in legends, arc labels, and accessible output. | diff --git a/docs/charts/line-and-area.md b/docs/charts/line-and-area.md deleted file mode 100644 index d94094fd..00000000 --- a/docs/charts/line-and-area.md +++ /dev/null @@ -1,365 +0,0 @@ ---- -title: Line and Area Charts -description: Build line and area charts with object-row data, renderers, gestures, and themes. ---- - -# Line and Area Charts - -`LineChart` is the primary modern v2 chart surface. It uses object-row data, -explicit keys, renderer-agnostic geometry, and SVG rendering by default. - -Use this API for new apps. The legacy `react-native-chart-kit` data shape is handled separately by the compatibility facade. - -## Basic Line - -```tsx -import { LineChart } from "react-native-chart-kit/v2"; - -const data = [ - { month: "Jan", revenue: 18 }, - { month: "Feb", revenue: 34 }, - { month: "Mar", revenue: 29 }, - { month: "Apr", revenue: 52 } -]; - -export function RevenueChart() { - return ( - - ); -} -``` - -::chart-preview{id="line-basic"} - -## Multi-Series - -Use `series` when each line needs its own label, color, marker, curve, or stroke style. - -```tsx - -``` - -::chart-preview{id="line-multi-series"} - -## Area Fill - -`AreaChart` is an alias for `LineChart` with `area` enabled. You can also opt into area fill per chart or per series. - -```tsx - -``` - -::chart-preview{id="line-area"} - -Use `areaFill` on the chart for a shared fill style, or per series when each series needs its own gradient colors or opacity. - -## Styling Lines and Dots - -Supported curve values are `linear`, `monotone`, and `step`. - -```tsx - -``` - -## Threshold Coloring - -Threshold coloring clips the rendered path and area fill above or below a y value. Raw points are unchanged. - -```tsx - -``` - -## Tooltips and Selection - -Selection state is shared by tooltips, active dots, crosshairs, and external UI through `onSelect`. - -```tsx - { - setHeaderValue(event.series[0]?.formattedValue); - } - }} - tooltip={{ - shared: true, - anchor: "pointer", - placement: "above", - offset: 18, - positionAnimationDuration: 320 - }} - crosshair - width={360} - height={260} -/> -``` - -::chart-preview{id="line-selection"} - -Selection modes: - -- `none`: no chart selection. -- `tap`: tap to select the nearest x value. -- `scrub`: press and drag to update the nearest x value. - -Selection persistence: - -- `persist`: keep the last selection after the gesture ends. -- `whileActive`: clear selection on gesture end. -- `none`: emit selection events without keeping internal selected state. - -Tooltip positioning: - -- `anchor: "point"` positions around the selected data point. -- `anchor: "pointer"` positions around the touch/mouse pointer, useful for scrub. -- `placement: "auto" | "above" | "below"` controls vertical placement while preserving edge clamping. - -## Custom Crosshair - -Use `renderCrosshair` when a product needs branded cursors, axis badges, or a custom inspection overlay. The render prop receives the selected x/y position, selected series, plot bounds, theme tokens, and resolved crosshair config. - -```tsx -import { G, Line, Text as SvgText } from "react-native-svg"; - - ( - - - - {xLabel}: {series[0]?.formattedValue} - - - )} - width={360} - height={260} -/>; -``` - -## Scroll, Pan, Zoom, and Range Selector - -Use simple horizontal scrolling for long categorical or time-series charts. - -```tsx - -``` - -Use a controlled viewport for direct pan, pinch zoom, or a mini-chart range selector. - -```tsx -const [viewport, setViewport] = useState({ - startIndex: 40, - endIndex: 90 -}); - - setViewport(event.viewport)} - viewportInteraction={{ pan: true, pinchZoom: true, lockParentScroll: true }} - rangeSelector={{ visible: true, interactive: true, height: 68 }} - yAxisLabelWidth="stable" - width={360} - height={340} -/>; -``` - -`yAxisLabelWidth="stable"` reserves label width from the full dataset, so changing the viewport does not make the plot jump when labels change from values such as `$10k` to `$100k`. - -The range selector is composable through `renderLine`, `renderHandle`, and `renderWindow`, so products can brand the overview path, handles, and selected window without replacing the built-in viewport logic. - -## Reference Overlays - -Reference lines and bands are clipped to the plot bounds. Line labels default to automatic vertical placement and try to avoid nearby series geometry. - -```tsx - -``` - -Set `labelPlacement="above"` or `labelPlacement="below"` only when you need a fixed position. - -## Layout Debug - -Use `debugLayout` in development when a chart clips labels, legends, or tooltips. The overlay draws the outer bounds, plot bounds, visible label boxes, legend boxes, and tooltip box. - -```tsx - { - console.log(model.rects); - }} - width={360} - height={240} -/> -``` - -`onLayoutDebug` receives the same rectangle model that the overlay renders, so bug reports can include structured layout evidence alongside screenshots. - -## Labels and Axes - -Useful label props: - -- `labelStrategy`: `auto`, `show`, `skip`, `rotate`, `stagger`, or `hide`. -- `edgeLabelPolicy`: `shift`, `hide`, or `show`. -- `formatXLabel`: format dates, categories, or numeric x values. -- `formatYLabel`: format y-axis labels and tooltip values. -- `axisLabelAnimation`: crossfade y-axis label changes during viewport changes. -- `yAxisLabelWidth`: `auto`, `stable`, or a fixed number. - -When `labelStrategy` is `auto` or `skip`, duplicate formatted x-axis labels are collapsed before collision solving. Use `labelStrategy="show"` when repeated labels are intentional. - -## Decimation - -LineChart uses automatic path-only min/max decimation by default. This reduces SVG path complexity for dense charts while preserving source points for selection, tooltips, labels, and custom dots. - -```tsx - -``` - -Decimation options: - -- `decimation="auto"`: default. Uses roughly two rendered path points per plot pixel, with a minimum of 120. -- `decimation={false}`: disables path decimation. -- `decimation={500}`: caps each rendered path around a fixed point budget. -- `decimation={{ maxPoints: 700 }}`: object form for future strategy options. - -For large charts, set `showDots={false}` unless every marker is intentional. Decimation only affects paths; dots remain exact because custom dots may encode business meaning. - -## Accessibility - -Every LineChart generates a summary if `accessibilityLabel` is not provided. For custom accessibility surfaces, use: - -```ts -import { - getLineChartAccessibilitySummary, - getLineChartDataTable -} from "react-native-chart-kit/v2"; -``` - -`getLineChartDataTable()` returns columns and rows suitable for an app-level table fallback or export workflow. diff --git a/docs/charts/line.md b/docs/charts/line.md new file mode 100644 index 00000000..b7966b89 --- /dev/null +++ b/docs/charts/line.md @@ -0,0 +1,485 @@ +--- +title: Line Chart +description: Build line charts with object-row data, renderers, gestures, and themes. +--- + +# Line Chart + +`LineChart` is the primary modern v2 trend surface. It uses object-row data, +explicit keys, renderer-agnostic geometry, and SVG rendering by default. + +Use this API for new apps. The legacy `react-native-chart-kit` data shape is +handled separately by the compatibility facade. + +## Basic Line + +```tsx +import { LineChart } from "react-native-chart-kit/v2"; + +const data = [ + { month: "Jan", revenue: 52 }, + { month: "Feb", revenue: 86 }, + { month: "Mar", revenue: 58 }, + { month: "Apr", revenue: 134 }, + { month: "May", revenue: 95 }, + { month: "Jun", revenue: 176 }, + { month: "Jul", revenue: 126 }, + { month: "Aug", revenue: 218 }, + { month: "Sep", revenue: 164 }, + { month: "Oct", revenue: 252 }, + { month: "Nov", revenue: 198 }, + { month: "Dec", revenue: 286 } +]; + +export function RevenueChart() { + return ( + + ); +} +``` + +::chart-preview{id="line-basic"} + +## Multi-Series + +Use `series` when each line needs its own label, color, marker, curve, or +stroke style. + +```tsx +const data = [ + { month: "Jan", actual: 22, forecast: 190 }, + { month: "Feb", actual: 64, forecast: 168 }, + { month: "Mar", actual: 39, forecast: 143 }, + { month: "Apr", actual: 118, forecast: 122 }, + { month: "May", actual: 73, forecast: 101 }, + { month: "Jun", actual: 161, forecast: 84 }, + { month: "Jul", actual: 109, forecast: 66 }, + { month: "Aug", actual: 204, forecast: 48 } +]; + +; +``` + +::chart-preview{id="line-multi-series"} + +## Styling Lines and Dots + +Supported curve values are `linear`, `monotone`, and `step`. + +```tsx +const data = [ + { date: "Mon", portfolio: 512, benchmark: 690 }, + { date: "Tue", portfolio: 660, benchmark: 610 }, + { date: "Wed", portfolio: 430, benchmark: 650 }, + { date: "Thu", portfolio: 760, benchmark: 540 }, + { date: "Fri", portfolio: 590, benchmark: 700 }, + { date: "Sat", portfolio: 820, benchmark: 520 }, + { date: "Sun", portfolio: 548, benchmark: 735 } +]; + +; +``` + +## Threshold Coloring + +Threshold coloring clips the rendered path above or below a y value. Raw points +are unchanged. + +```tsx +const data = [ + { month: "Jan", attainment: 62 }, + { month: "Feb", attainment: 138 }, + { month: "Mar", attainment: 74 }, + { month: "Apr", attainment: 151 }, + { month: "May", attainment: 89 }, + { month: "Jun", attainment: 122 }, + { month: "Jul", attainment: 55 }, + { month: "Aug", attainment: 164 }, + { month: "Sep", attainment: 96 }, + { month: "Oct", attainment: 145 }, + { month: "Nov", attainment: 78 }, + { month: "Dec", attainment: 172 } +]; + +; +``` + +## Tooltips and Selection + +Selection state is shared by tooltips, active dots, crosshairs, and external UI +through `onSelect`. + +```tsx +const data = [ + { date: "Nov 03", portfolio: 512, benchmark: 720 }, + { date: "Nov 04", portfolio: 681, benchmark: 604 }, + { date: "Nov 05", portfolio: 438, benchmark: 698 }, + { date: "Nov 10", portfolio: 794, benchmark: 552 }, + { date: "Nov 11", portfolio: 566, benchmark: 746 }, + { date: "Nov 12", portfolio: 842, benchmark: 580 }, + { date: "Nov 13", portfolio: 618, benchmark: 790 }, + { date: "Nov 14", portfolio: 906, benchmark: 534 } +]; + + { + setHeaderValue(event.series[0]?.formattedValue); + } + }} + tooltip={{ + shared: true, + anchor: "pointer", + placement: "above", + offset: 18, + positionAnimationDuration: 320 + }} + crosshair + width={360} + height={260} +/>; +``` + +::chart-preview{id="line-selection"} + +Selection modes: + +- `none`: no chart selection. +- `tap`: tap to select the nearest x value. +- `scrub`: press and drag to update the nearest x value. + +Selection persistence: + +- `persist`: keep the last selection after the gesture ends. +- `whileActive`: clear selection on gesture end. +- `none`: emit selection events without keeping internal selected state. + +Tooltip positioning: + +- `anchor: "point"` positions around the selected data point. +- `anchor: "pointer"` positions around the touch/mouse pointer. +- `placement: "auto" | "above" | "below"` controls vertical placement while + preserving edge clamping. + +## Custom Crosshair + +Use `renderCrosshair` when a product needs branded cursors, axis badges, or a +custom inspection overlay. + +```tsx +import { G, Line, Text as SvgText } from "react-native-svg"; + +const data = [ + { month: "Jan", actual: 22, forecast: 190 }, + { month: "Feb", actual: 64, forecast: 168 }, + { month: "Mar", actual: 39, forecast: 143 }, + { month: "Apr", actual: 118, forecast: 122 }, + { month: "May", actual: 73, forecast: 101 }, + { month: "Jun", actual: 161, forecast: 84 }, + { month: "Jul", actual: 109, forecast: 66 }, + { month: "Aug", actual: 204, forecast: 48 } +]; + + ( + + + + {xLabel}: {series[0]?.formattedValue} + + + )} + width={360} + height={260} +/>; +``` + +## Scroll, Pan, Zoom, and Range Selector + +Use simple horizontal scrolling for long categorical or time-series charts. + +```tsx + +``` + +Use a controlled viewport for direct pan, pinch zoom, or a mini-chart range +selector. + +```tsx +const [viewport, setViewport] = useState({ + startIndex: 40, + endIndex: 90 +}); + + setViewport(event.viewport)} + viewportInteraction={{ pan: true, pinchZoom: true, lockParentScroll: true }} + rangeSelector={{ visible: true, interactive: true, height: 68 }} + yAxisLabelWidth="stable" + width={360} + height={340} +/>; +``` + +`yAxisLabelWidth="stable"` reserves label width from the full dataset, so +changing the viewport does not make the plot jump when labels change. + +## Reference Overlays + +Reference lines and bands are clipped to the plot bounds. Line labels default to +automatic vertical placement and try to avoid nearby series geometry. + +```tsx +const data = [ + { month: "Jan", attainment: 62 }, + { month: "Feb", attainment: 138 }, + { month: "Mar", attainment: 74 }, + { month: "Apr", attainment: 151 }, + { month: "May", attainment: 89 }, + { month: "Jun", attainment: 122 }, + { month: "Jul", attainment: 55 }, + { month: "Aug", attainment: 164 }, + { month: "Sep", attainment: 96 }, + { month: "Oct", attainment: 145 }, + { month: "Nov", attainment: 78 }, + { month: "Dec", attainment: 172 } +]; + +; +``` + +## Layout Debug + +Use `debugLayout` in development when a chart clips labels, legends, or tooltips. + +```tsx + { + console.log(model.rects); + }} + width={360} + height={240} +/> +``` + +## Labels and Axes + +Useful label props: + +- `labelStrategy`: `auto`, `show`, `skip`, `rotate`, `stagger`, or `hide`. +- `edgeLabelPolicy`: `shift`, `hide`, or `show`. +- `formatXLabel`: format dates, categories, or numeric x values. +- `formatYLabel`: format y-axis labels and tooltip values. +- `axisLabelAnimation`: crossfade y-axis label changes during viewport changes. +- `yAxisLabelWidth`: `auto`, `stable`, or a fixed number. + +## Decimation + +LineChart uses automatic path-only min/max decimation by default. This reduces +SVG path complexity for dense charts while preserving source points for +selection, tooltips, labels, and custom dots. + +```tsx + +``` + +Decimation options: + +- `decimation="auto"`: default. Uses roughly two rendered path points per plot + pixel, with a minimum of 120. +- `decimation={false}`: disables path decimation. +- `decimation={500}`: caps each rendered path around a fixed point budget. +- `decimation={{ maxPoints: 700 }}`: object form for future strategy options. + +## Accessibility + +Every LineChart generates a summary if `accessibilityLabel` is not provided. For +custom accessibility surfaces, use: + +```ts +import { + getLineChartAccessibilitySummary, + getLineChartDataTable +} from "react-native-chart-kit/v2"; +``` + +`getLineChartDataTable()` returns columns and rows suitable for an app-level +table fallback or export workflow. + +## Props + +### LineChart + +| Prop | Type | Description | +| ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `data` | `TData[]` | Object-row source data for the chart. | +| `xKey` | `keyof TData` | Row key used for the x-axis value. | +| `yKey` | `keyof TData` | Single row key used for y values when `series` or `yKeys` is not provided. | +| `yKeys` | `Array` | Multiple row keys rendered as separate series with default styling. | +| `series` | `LineChartSeries[]` | Full per-series configuration, including labels, colors, stroke, dots, thresholds, and area fill. | +| `width` | `number` | Outer chart width in pixels. | +| `height` | `number` | Outer chart height in pixels. | +| `theme` | `ChartKitThemeMode` or `CartesianChartTheme` | Theme mode or inline theme tokens for this chart. | +| `preset` | `CartesianChartPresetValue` | Built-in or registered preset name used to seed chart colors and typography. | +| `scrollable` | `boolean` | Enables a horizontal scroll viewport for long data sets. | +| `visiblePoints` | `number` | Number of points visible in the viewport when `scrollable` is enabled. | +| `initialIndex` | `ChartViewportInitialIndex` | Initial scroll/window position, such as `"start"` or `"end"`. | +| `viewport` | `LineChartViewportConfig` | Controlled visible data window for scroll, pan, zoom, or range selector flows. | +| `onViewportChange` | `(event) => void` | Called when viewport state changes from the main plot or range selector. | +| `viewportInteraction` | `boolean` or `LineChartViewportInteractionConfig` | Enables and configures one-finger pan and pinch zoom for the viewport. | +| `rangeSelector` | `boolean` or `LineChartRangeSelectorConfig` | Adds a mini-chart range selector below the main plot. | +| `decimation` | `false`, `"auto"`, `number`, or `LineChartDecimationConfig` | Controls rendered path simplification for dense series. | +| `curve` | `LineCurve` | Curve interpolation used for line and area paths. | +| `connectNulls` | `boolean` | Connects defined points across `null` or missing y values. | +| `area` | `boolean` | Renders area fills under the line series. | +| `areaFill` | `LineChartAreaFillConfig` | Shared area fill opacity, gradient, or color configuration. | +| `showDots` | `boolean` | Shows or hides all point markers. | +| `dots` | `boolean` or `LineChartDotConfig` | Configures default point marker visibility, size, shape, and color. | +| `renderDot` | `(props) => ReactNode` | Custom renderer for ordinary point markers. | +| `selectedIndex` | `number` | Controlled selected data index. | +| `defaultSelectedIndex` | `number` | Initial uncontrolled selected data index. | +| `activeDot` | `boolean` or `LineChartDotConfig` | Configures the marker shown for the selected point. | +| `renderActiveDot` | `(props) => ReactNode` | Custom renderer for the selected point marker. | +| `interaction` | `LineChartInteraction` | Selection/scrub interaction mode and callbacks. | +| `crosshair` | `boolean` or `LineChartCrosshairConfig` | Shows and configures the selected-point crosshair. | +| `renderCrosshair` | `(props) => ReactNode` | Custom renderer for the crosshair. | +| `tooltip` | `boolean` or `LineChartTooltipConfig` | Shows and configures selected-point tooltip content and placement. | +| `renderTooltip` | `(props) => ReactNode` | Custom renderer for selected-point tooltip content. | +| `referenceLines` | `LineChartReferenceLineConfig[]` | Horizontal reference lines drawn across the plot. | +| `referenceBands` | `LineChartReferenceBandConfig[]` | Horizontal reference bands drawn behind the series. | +| `showHorizontalGridLines` | `boolean` | Shows or hides horizontal grid lines. | +| `showVerticalGridLines` | `boolean` | Shows or hides vertical grid lines. | +| `legend` | `boolean` or `LineChartLegendConfig` | Shows and configures the chart legend. | +| `labelStrategy` | `LineChartLabelStrategy` | Controls x-axis label density and layout. | +| `labelRotation` | `number` | Rotation angle for x-axis labels when using rotated labels. | +| `labelMinGap` | `number` | Minimum gap used by automatic x-axis label skipping. | +| `edgeLabelPolicy` | `LineChartEdgeLabelPolicy` | Controls how first and last x-axis labels are shifted, hidden, or shown. | +| `yDomain` | `NumericDomainInput` | Overrides or constrains the computed y-axis domain. | +| `yAxisLabelWidth` | `LineChartYAxisLabelWidth` | Fixed, automatic, or stable width for y-axis labels. | +| `axisLabelAnimation` | `boolean` or `LineChartAxisLabelAnimationConfig` | Animates y-axis label changes during viewport updates. | +| `formatXLabel` | `(value, index) => string` | Formats x-axis labels and selected x labels. | +| `formatYLabel` | `(value) => string` | Formats y-axis labels, selected values, and tooltip values. | +| `renderer` | `LineChartRenderer` | Renderer implementation used for SVG-compatible primitives. | +| `id` | `string` | Stable chart id used for internal ids and coordinated selection scope. | +| `debugLayout` | `boolean` | Renders layout debug rectangles in development. | +| `onLayoutDebug` | `(model) => void` | Receives computed layout debug geometry. | +| `accessibilityLabel` | `string` | Overrides the generated accessible chart summary. | +| `testID` | `string` | Test identifier applied to the chart surface. | diff --git a/docs/charts/pie-and-donut.md b/docs/charts/pie-and-donut.md deleted file mode 100644 index 16b2c4f6..00000000 --- a/docs/charts/pie-and-donut.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -title: Pie and Donut Charts -description: Render segmented pie and donut charts with accessible legends and labels. ---- - -# Pie and Donut Charts - -`PieChart` and `DonutChart` use the same normalized slice model and -renderer-agnostic arc geometry. - -## Pie Chart - -```tsx -import { PieChart } from "react-native-chart-kit/v2"; - -const data = [ - { channel: "Organic search", share: 42 }, - { channel: "Paid social", share: 24 }, - { channel: "Referrals", share: 18 }, - { channel: "Partners", share: 10 }, - { channel: "Lifecycle", share: 6 } -]; - -export function AcquisitionShare() { - return ( - - ); -} -``` - -::chart-preview{id="pie-basic"} - -## Donut Chart - -Use `DonutChart` for the default donut radius, or pass `innerRadius` / `innerRadiusRatio` directly to `PieChart`. - -```tsx -import { DonutChart } from "react-native-chart-kit/v2"; - -; -``` - -::chart-preview{id="donut-basic"} - -## Current Scope - -The first v2 slice supports: - -- modern object-row data -- theme and preset colors -- bottom wrapped legend -- percentage labels in the legend -- donut center text -- rich custom center labels -- custom legend item rendering -- external arc labels with connector lines -- tap selection with active-slice highlighting -- zero and invalid slices without broken paths - -## Tap Selection - -Use `interaction="tap"` for uncontrolled selection, or pass `selectedIndex` with `interaction.onSelect` for controlled product UI. - -```tsx -const [selectedIndex, setSelectedIndex] = useState(0); - - setSelectedIndex(event.index) - }} - centerLabel={revenueMix[selectedIndex]?.plan} - activeSlice={{ inactiveOpacity: 0.36, strokeWidth: 4 }} - width={360} - height={260} -/>; -``` - -## Custom Legend And Center Label - -Use `legend.renderItem` when the default compact legend is not enough. `centerLabel` can return React content for multi-line KPI labels. - -```tsx - ( - - {total} - accounts - - )} - legend={{ - maxItemWidth: "100%", - renderItem: ({ item, theme }) => ( - - {item.label} - {item.percentageLabel} - - ) - }} - width={360} - height={300} -/> -``` - -## External Arc Labels - -Use `arcLabels` when the chart should explain itself without a separate legend. Small slices are filtered by `minPercentage` so long-tail labels do not collide with the primary categories. - -```tsx - - `${label.split(" ")[0]} ${percentageLabel}` - }} - width={360} - height={260} -/> -``` diff --git a/docs/charts/pie.md b/docs/charts/pie.md new file mode 100644 index 00000000..4cf10711 --- /dev/null +++ b/docs/charts/pie.md @@ -0,0 +1,144 @@ +--- +title: Pie Chart +description: Render segmented pie charts with accessible legends and labels. +--- + +# Pie Chart + +`PieChart` renders a normalized slice model from object-row data and +renderer-agnostic arc geometry. + +## Basic Pie + +```tsx +import { PieChart } from "react-native-chart-kit/v2"; + +const data = [ + { channel: "Organic search", share: 42 }, + { channel: "Paid social", share: 24 }, + { channel: "Referrals", share: 18 }, + { channel: "Partners", share: 10 }, + { channel: "Lifecycle", share: 6 } +]; + +export function AcquisitionShare() { + return ( + + ); +} +``` + +::chart-preview{id="pie-basic"} + +## Current Scope + +The first v2 pie chart supports: + +- modern object-row data +- theme and preset colors +- bottom wrapped legend +- percentage labels in the legend +- custom legend item rendering +- external arc labels with connector lines +- tap selection with active-slice highlighting +- zero and invalid slices without broken paths + +## Tap Selection + +Use `interaction="tap"` for uncontrolled selection, or pass `selectedIndex` with +`interaction.onSelect` for controlled product UI. + +```tsx +const acquisitionShare = [ + { channel: "Organic search", share: 42 }, + { channel: "Paid social", share: 24 }, + { channel: "Referrals", share: 18 }, + { channel: "Partners", share: 10 }, + { channel: "Lifecycle", share: 6 } +]; + +const [selectedIndex, setSelectedIndex] = useState(0); + + setSelectedIndex(event.index) + }} + activeSlice={{ inactiveOpacity: 0.36, strokeWidth: 4 }} + width={360} + height={260} +/>; +``` + +## External Arc Labels + +Use `arcLabels` when the chart should explain itself without a separate legend. +Small slices are filtered by `minPercentage` so long-tail labels do not collide +with the primary categories. + +```tsx +const acquisitionShare = [ + { channel: "Organic search", share: 42 }, + { channel: "Paid social", share: 24 }, + { channel: "Referrals", share: 18 }, + { channel: "Partners", share: 10 }, + { channel: "Lifecycle", share: 6 } +]; + + + `${label.split(" ")[0]} ${percentageLabel}` + }} + width={360} + height={260} +/>; +``` + +## Props + +### PieChart + +| Prop | Type | Description | +| ---------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `data` | `TData[]` | Object-row source data for the chart. | +| `valueKey` | `keyof TData` | Row key used for slice values. | +| `labelKey` | `keyof TData` | Row key used for slice and legend labels. | +| `colorKey` | `keyof TData` | Row key used for per-slice colors. | +| `colors` | `string[]` | Fallback color palette used when `colorKey` is not provided. | +| `width` | `number` | Outer chart width in pixels. | +| `height` | `number` | Outer chart height in pixels. | +| `theme` | `"light"`, `"dark"`, `"system"`, or `CartesianChartTheme` | Theme mode or inline theme tokens for this chart. | +| `preset` | `CartesianChartPresetValue` | Built-in or registered preset name used to seed chart colors and typography. | +| `innerRadius` | `number` | Explicit inner radius in pixels for donut-style rendering. | +| `innerRadiusRatio` | `number` | Inner radius as a fraction of the computed outer radius. | +| `legend` | `boolean` or `PieChartLegendConfig` | Shows and configures the wrapped legend. | +| `arcLabels` | `boolean` or `PieChartArcLabelsConfig` | Shows and configures external arc labels and connector lines. | +| `selectedIndex` | `number` | Controlled selected slice index. | +| `defaultSelectedIndex` | `number` | Initial uncontrolled selected slice index. | +| `activeSlice` | `PieChartActiveSliceConfig` | Configures selected-slice stroke, opacity, offset, and scale. | +| `selectionAnimation` | `boolean` or `PieChartSelectionAnimationConfig` | Enables and configures selected-slice animation. | +| `interaction` | `PieChartInteraction` | Tap selection mode and callbacks. | +| `centerLabel` | `string`, `ReactNode`, or `(props) => ReactNode` | Content rendered in the chart center. | +| `renderer` | `PieChartRenderer` | Renderer implementation used for SVG-compatible primitives. | +| `accessibilityLabel` | `string` | Overrides the generated accessible chart summary. | +| `id` | `string` | Stable chart id used for coordinated selection scope. | +| `testID` | `string` | Test identifier applied to the chart surface. | +| `formatValue` | `(value) => string` | Formats raw slice values in labels and accessible output. | +| `formatPercentage` | `(percentage) => string` | Formats percentage labels in legends, arc labels, and accessible output. | diff --git a/docs/charts/progress.md b/docs/charts/progress.md index 2b20798d..c392d093 100644 --- a/docs/charts/progress.md +++ b/docs/charts/progress.md @@ -7,13 +7,15 @@ description: Show circular progress values with configurable labels and accessib The v2 progress surface supports concentric rings and single-ring completion states. It accepts object rows for the modern API and the legacy Chart Kit progress data shape. +## Concentric Rings + ```tsx -import { ProgressChart, ProgressRing } from "react-native-chart-kit/v2"; +import { ProgressChart } from "react-native-chart-kit/v2"; const data = [ - { metric: "Move", progress: 0.72, color: "#f43f5e" }, - { metric: "Exercise", progress: 0.48, color: "#22c55e" }, - { metric: "Stand", progress: 0.9, color: "#2563eb" } + { metric: "Build signed", progress: 0.76, color: "#00163f" }, + { metric: "QA pass", progress: 0, color: "#2f5f9f" }, + { metric: "Rollout cap", progress: 0.42, color: "#6f88aa" } ]; `${Math.round(average * 100)}%`} />; +``` + +::chart-preview{id="progress-rings"} + +## Single Ring + +```tsx +import { ProgressRing } from "react-native-chart-kit/v2"; ; ``` -::chart-preview{id="progress-rings"} - ## Compatibility Shape The legacy data object still works: @@ -45,9 +53,9 @@ The legacy data object still works: ```tsx ``` + +## Props + +### ProgressChart + +| Prop | Type | Description | +| --------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `data` | `ProgressChartData` | Modern object-row data or the legacy progress data object. | +| `valueKey` | `keyof TData` | Row key used for ring progress values in object-row data. | +| `labelKey` | `keyof TData` | Row key used for ring labels in object-row data. | +| `colorKey` | `keyof TData` | Row key used for ring colors in object-row data. | +| `labels` | `string[]` | Labels used with legacy data arrays. | +| `colors` | `string[]` | Colors used with legacy data arrays or as fallback ring colors. | +| `width` | `number` | Outer chart width in pixels. | +| `height` | `number` | Outer chart height in pixels. | +| `theme` | `"light"`, `"dark"`, `"system"`, or `CartesianChartTheme` | Theme mode or inline theme tokens for this chart. | +| `preset` | `CartesianChartPresetValue` | Built-in or registered preset name used to seed chart colors and typography. | +| `legend` | `boolean` or `ProgressChartLegendConfig` | Shows and configures the legend. | +| `hideLegend` | `boolean` | Legacy-style shortcut for hiding the legend. | +| `centerLabel` | `string` or `(props) => string` | Text rendered in the center of the rings. | +| `strokeWidth` | `number` | Ring stroke width in pixels. | +| `ringGap` | `number` | Gap between concentric rings. | +| `radius` | `number` | Explicit outer ring radius in pixels. | +| `animation` | `boolean` or `ProgressChartAnimationConfig` | Enables and configures ring entrance/update animation. | +| `strokeLinecap` | `ProgressChartStrokeLinecap` | Stroke cap style for progress arcs. | +| `backgroundRingColor` | `string` | Color used for ring tracks behind progress arcs. | +| `renderer` | `ProgressChartRenderer` | Renderer implementation used for SVG-compatible primitives. | +| `accessibilityLabel` | `string` | Overrides the generated accessible chart summary. | +| `testID` | `string` | Test identifier applied to the chart surface. | +| `formatPercentage` | `(value) => string` | Formats percentages in labels and accessible output. | + +### ProgressRing + +| Prop | Type | Description | +| --------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `value` | `number`, `null`, or `undefined` | Single progress value for the ring, usually between `0` and `1`. | +| `label` | `string` | Label used for the ring and accessible output. | +| `color` | `string` | Progress arc color for the ring. | +| `width` | `number` | Outer chart width in pixels. | +| `height` | `number` | Outer chart height in pixels. | +| `theme` | `"light"`, `"dark"`, `"system"`, or `CartesianChartTheme` | Theme mode or inline theme tokens for this chart. | +| `preset` | `CartesianChartPresetValue` | Built-in or registered preset name used to seed chart colors and typography. | +| `legend` | `boolean` or `ProgressChartLegendConfig` | Shows and configures the legend. | +| `hideLegend` | `boolean` | Legacy-style shortcut for hiding the legend. | +| `centerLabel` | `string` or `(props) => string` | Text rendered in the ring center. | +| `strokeWidth` | `number` | Ring stroke width in pixels. | +| `ringGap` | `number` | Gap setting inherited from `ProgressChart`; only relevant when the component is wrapped or extended. | +| `radius` | `number` | Explicit ring radius in pixels. | +| `animation` | `boolean` or `ProgressChartAnimationConfig` | Enables and configures ring entrance/update animation. | +| `strokeLinecap` | `ProgressChartStrokeLinecap` | Stroke cap style for the progress arc. | +| `backgroundRingColor` | `string` | Color used for the track behind the progress arc. | +| `renderer` | `ProgressChartRenderer` | Renderer implementation used for SVG-compatible primitives. | +| `accessibilityLabel` | `string` | Overrides the generated accessible chart summary. | +| `testID` | `string` | Test identifier applied to the chart surface. | +| `formatPercentage` | `(value) => string` | Formats percentages in labels and accessible output. | diff --git a/docs/charts/radar.md b/docs/charts/radar.md new file mode 100644 index 00000000..59e922fc --- /dev/null +++ b/docs/charts/radar.md @@ -0,0 +1,70 @@ +--- +title: Radar Chart +description: Compare KPI profiles, benchmarks, and capability scores with Chart Kit Pro. +--- + +# Radar Chart + +`RadarChart` is a Pro chart for comparing multiple metric profiles across a +fixed set of dimensions. It is useful for product quality, health scores, +benchmarks, rubric scoring, and portfolio comparison. + +This chart is available in Chart Kit Pro. + +## Basic Radar + +```tsx +import { RadarChart } from "@chart-kit/pro"; + +const benchmarks = [ + { metric: "Speed", current: 82, target: 92, industry: 68 }, + { metric: "Polish", current: 76, target: 88, industry: 61 }, + { metric: "A11y", current: 90, target: 94, industry: 72 }, + { metric: "Depth", current: 68, target: 84, industry: 55 }, + { metric: "Control", current: 86, target: 91, industry: 65 }, + { metric: "Export", current: 72, target: 86, industry: 58 } +]; + +export function QualityRadar() { + return ( + + ); +} +``` + +::chart-preview{id="pro-radar"} + +## Product Use Cases + +Use Radar charts for quality rubrics, health checks, skills matrices, competitor +benchmarks, feature maturity reports, and product scorecards. + +## Install + +```sh +npm install react-native-chart-kit @chart-kit/pro react-native-svg +``` + +Access to `@chart-kit/pro` requires a Pro license. + +## Props + +| Prop | Type | Description | +| ------------- | -------------------- | ------------------------------------ | +| `data` | `TData[]` | Object-row source data. | +| `categoryKey` | `keyof TData` | Row key used for axis labels. | +| `series` | `RadarChartSeries[]` | Values rendered as filled polygons. | +| `maxValue` | `number` | Optional fixed radial scale maximum. | +| `width` | `number` | Outer chart width in pixels. | +| `height` | `number` | Outer chart height in pixels. | diff --git a/docs/charts/themes.md b/docs/charts/themes.md index 21cac82c..f0de278e 100644 --- a/docs/charts/themes.md +++ b/docs/charts/themes.md @@ -24,6 +24,15 @@ const acme = createChartPreset({ } }); +const data = [ + { date: "Jan", revenue: 52 }, + { date: "Feb", revenue: 86 }, + { date: "Mar", revenue: 58 }, + { date: "Apr", revenue: 134 }, + { date: "May", revenue: 95 }, + { date: "Jun", revenue: 176 } +]; + ; @@ -44,3 +53,16 @@ Built-in presets include: - `darkFintech` or `dark-fintech` Use `theme` for one-off chart overrides and `createChartPreset()` for design-system presets that should be shared across a product. + +## Props + +### ChartKitProvider + +| Prop | Type | Description | +| ---------- | ------------------------------ | ------------------------------------------------------------------ | +| `children` | `ReactNode` | Chart subtree that should inherit provider defaults. | +| `mode` | `ChartKitThemeMode` | Theme mode to apply, including `"light"`, `"dark"`, or `"system"`. | +| `preset` | `CartesianChartPresetValue` | Default preset name or preset object used by descendant charts. | +| `presets` | `CartesianChartPresetRegistry` | Custom preset registry available to descendant charts. | +| `renderer` | `LineChartRenderer` | Default SVG-compatible renderer used by descendant chart surfaces. | +| `theme` | `CartesianChartTheme` | Inline theme overrides merged into descendant chart themes. | diff --git a/docs/getting-started/contributing.md b/docs/getting-started/contributing.md new file mode 100644 index 00000000..542d3bfd --- /dev/null +++ b/docs/getting-started/contributing.md @@ -0,0 +1,83 @@ +--- +title: Contributing +description: Set up the React Native Chart Kit repository and run local development checks. +--- + +# Contributing + +This page is for developing React Native Chart Kit itself. If you are installing +the library in an app, start with the [Quickstart](installation.md). + +## Repository Setup + +```sh +git clone git@github.com:indiespirit/react-native-chart-kit.git +cd react-native-chart-kit +npm install +``` + +The repository uses npm workspaces and `package-lock.json`. The published npm +package is `react-native-chart-kit`; the `@chart-kit/*` workspaces are internal +packages used to develop and build the modern implementation. + +## Local Checks + +Run the focused check for the area you changed, then run the broader checks +before opening a pull request. + +```sh +npm run lint +npm run typecheck +npm run test +npm run docs:build +npm run build +``` + +Useful focused commands: + +| Command | Purpose | +| ---------------------------------- | -------------------------------------------------------- | +| `npm run core:typecheck` | Type-check the v2 core workspace. | +| `npm run svg:typecheck` | Type-check the SVG renderer workspace. | +| `npm run rn:typecheck` | Type-check the React Native workspace. | +| `npm run test:unit` | Run unit tests. | +| `npm run test:compat` | Run legacy compatibility tests. | +| `npm run example:rn-cli:typecheck` | Type-check the React Native CLI smoke example. | +| `npm run docs:build` | Verify docs links and type-check documentation examples. | +| `npm run pack:check` | Dry-run package artifacts for the public package. | +| `npm run surface:check` | Check public exports and package boundaries. | + +## Example App + +The public repo includes a React Native CLI smoke surface for non-Expo import and +peer-dependency checks: + +```sh +npm run example:rn-cli:typecheck +``` + +See `examples/rn-cli-basic` for the app source and Metro aliases. + +The Expo preview app lives in the private `chart-kit-pro` repository because it +combines free and Pro chart examples. + +## Branch Names + +Use lowercase kebab-case branch names in this format: + +```sh +/ +``` + +Use these branch types: + +- `fix/` for user-visible bug fixes +- `feat/` for new public functionality +- `docs/` for documentation-only changes +- `ci/` for automation and CI changes +- `chore/` for maintenance, release hygiene, and dependency upkeep +- `refactor/` for internal changes that do not intentionally change behavior +- `release/` for version bump and publishing prep + +Include an issue number when the branch maps to a specific issue, for example +`fix/733-svg-gradient-ids`. Keep each branch scoped to one pull request. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 4991f111..58b88873 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -5,48 +5,49 @@ description: Install React Native Chart Kit and render a first chart. # Quickstart -Chart Kit v2 is developed in private repo-internal `@chart-kit/*` workspaces, -but `react-native-chart-kit` is the only public npm install path. +Install the public package in your React Native app. Do not install +`@chart-kit/*` packages directly; those are repo-internal workspaces. -For local development in this repository: +## React Native CLI ```sh -git clone git@github.com:indiespirit/react-native-chart-kit.git -cd react-native-chart-kit -npm install +npm install react-native-chart-kit react-native-svg ``` -For apps: +For iOS apps, install native pods after installing dependencies: ```sh -npm install react-native-chart-kit react-native-svg +cd ios +pod install ``` -The root import remains the legacy-compatible API. The modern free v2 API is -available from the `react-native-chart-kit/v2` subpath. - ## Expo -For Expo apps, install the native peer dependencies with Expo so versions match the SDK: +Install the package and the Expo-compatible `react-native-svg` version: ```sh +npm install react-native-chart-kit npx expo install react-native-svg ``` -Baseline tap, scrub, pan, pinch zoom, and range-selector interactions use React Native responder APIs, so new apps do not need a gesture-handler wrapper just to render or inspect charts. +Baseline tap, scrub, pan, pinch zoom, and range-selector interactions use React +Native responder APIs, so new apps do not need a gesture-handler wrapper just to +render or inspect charts. ## First Modern Chart -New v2 screens can import the modern API from the public package subpath. +New screens can import the modern v2 API from the public package subpath. ```tsx import { LineChart } from "react-native-chart-kit/v2"; const data = [ - { month: "Jan", revenue: 18 }, - { month: "Feb", revenue: 34 }, - { month: "Mar", revenue: 29 }, - { month: "Apr", revenue: 52 } + { month: "Jan", revenue: 52 }, + { month: "Feb", revenue: 86 }, + { month: "Mar", revenue: 58 }, + { month: "Apr", revenue: 134 }, + { month: "May", revenue: 95 }, + { month: "Jun", revenue: 176 } ]; export function RevenueChart() { @@ -64,17 +65,3 @@ export function RevenueChart() { ``` The root import remains the legacy-compatible surface for existing screens. - -## Local Review App - -This public repo includes a React Native CLI smoke surface for non-Expo import -and peer-dependency checks: - -```sh -npm run example:rn-cli:typecheck -``` - -See `examples/rn-cli-basic` for the app source and Metro aliases. - -The Expo preview app lives in the private `chart-kit-pro` repository because it -combines free and Pro chart examples. diff --git a/docs/migration/from-v1.md b/docs/migration/from-v1.md index 38044367..ec00e44a 100644 --- a/docs/migration/from-v1.md +++ b/docs/migration/from-v1.md @@ -50,9 +50,12 @@ import { LineChart } from "react-native-chart-kit/v2"; setSelectedDay(event)} width={360} diff --git a/eslint.config.mjs b/eslint.config.mjs index 20d67123..641bf8f3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,6 +9,8 @@ export default [ ignores: [ "dist/**", ".tmp/**", + "apps/site/.astro/**", + "apps/site/dist/**", "packages/*/dist/**", "node_modules/**", "coverage/**" diff --git a/package-lock.json b/package-lock.json index 9f480188..78c4de68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "dependencies": { "@tailwindcss/vite": "^4.3.0", "lucide-astro": "^0.556.0", + "react-live": "^4.1.8", "tailwindcss": "^4.3.0" }, "devDependencies": { @@ -3440,6 +3441,12 @@ "undici-types": "~7.19.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "devOptional": true, @@ -4153,6 +4160,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, "node_modules/anymatch": { "version": "3.1.3", "license": "ISC", @@ -8990,7 +9003,6 @@ }, "node_modules/lines-and-columns": { "version": "1.2.4", - "devOptional": true, "license": "MIT" }, "node_modules/lint-staged": { @@ -10868,6 +10880,17 @@ "dev": true, "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "funding": [ @@ -11030,7 +11053,6 @@ }, "node_modules/object-assign": { "version": "4.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11604,6 +11626,19 @@ "version": "18.3.1", "license": "MIT" }, + "node_modules/prism-react-renderer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", + "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", + "license": "MIT", + "dependencies": { + "@types/prismjs": "^1.26.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/prismjs": { "version": "1.30.0", "license": "MIT", @@ -11758,7 +11793,6 @@ }, "node_modules/react-dom": { "version": "19.2.6", - "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -11772,6 +11806,25 @@ "dev": true, "license": "MIT" }, + "node_modules/react-live": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/react-live/-/react-live-4.1.8.tgz", + "integrity": "sha512-B2SgNqwPuS2ekqj4lcxi5TibEcjWkdVyYykBEUBshPAPDQ527x2zPEZg560n8egNtAjUpwXFQm7pcXV65aAYmg==", + "license": "MIT", + "dependencies": { + "prism-react-renderer": "^2.4.0", + "sucrase": "^3.35.0", + "use-editable": "^2.3.3" + }, + "engines": { + "node": ">= 0.12.0", + "npm": ">= 2.0.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/react-native": { "version": "0.83.9", "license": "MIT", @@ -13311,6 +13364,37 @@ "dev": true, "license": "MIT" }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/suf-log": { "version": "2.5.3", "dev": true, @@ -13432,6 +13516,27 @@ "node": ">=8" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/throat": { "version": "5.0.0", "license": "MIT" @@ -13551,6 +13656,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, "node_modules/type-detect": { "version": "4.0.8", "license": "MIT", @@ -14234,6 +14345,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-editable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/use-editable/-/use-editable-2.3.3.tgz", + "integrity": "sha512-7wVD2JbfAFJ3DK0vITvXBdpd9JAz5BcKAAolsnLBuBn6UDDwBGuCIAGvR3yA2BNKm578vAMVHFCWaOcA+BhhiA==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "devOptional": true, diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 4eaa7a57..0bf5ca8e 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -25,9 +25,11 @@ Modern docs: - [Docs home](../../docs/README.md) - [Installation](../../docs/getting-started/installation.md) -- [Line and area charts](../../docs/charts/line-and-area.md) +- [Line charts](../../docs/charts/line.md) +- [Area charts](../../docs/charts/area.md) - [Bar charts](../../docs/charts/bar.md) -- [Pie and donut charts](../../docs/charts/pie-and-donut.md) +- [Pie charts](../../docs/charts/pie.md) +- [Donut charts](../../docs/charts/donut.md) - [Progress charts](../../docs/charts/progress.md) - [Contribution heatmap](../../docs/charts/contribution-heatmap.md) - [Migration from v1](../../docs/migration/from-v1.md) diff --git a/packages/react-native/src/charts/contribution/ContributionGraph.tsx b/packages/react-native/src/charts/contribution/ContributionGraph.tsx index 523a58ca..bfc76721 100644 --- a/packages/react-native/src/charts/contribution/ContributionGraph.tsx +++ b/packages/react-native/src/charts/contribution/ContributionGraph.tsx @@ -163,6 +163,7 @@ export const CalendarHeatmap = ContributionGraph; const styles = StyleSheet.create({ container: { + borderRadius: 8, overflow: "hidden" } }); diff --git a/packages/react-native/src/charts/pie/PieChart.tsx b/packages/react-native/src/charts/pie/PieChart.tsx index df636be3..b3659ec1 100644 --- a/packages/react-native/src/charts/pie/PieChart.tsx +++ b/packages/react-native/src/charts/pie/PieChart.tsx @@ -411,6 +411,7 @@ export const DonutChart = >( const styles = StyleSheet.create({ container: { + borderRadius: 8, overflow: "hidden" }, centerLabelOverlay: { diff --git a/packages/react-native/src/charts/progress/ProgressChart.tsx b/packages/react-native/src/charts/progress/ProgressChart.tsx index aa97abb8..1d100776 100644 --- a/packages/react-native/src/charts/progress/ProgressChart.tsx +++ b/packages/react-native/src/charts/progress/ProgressChart.tsx @@ -237,6 +237,7 @@ export const ProgressRing = ({ const styles = StyleSheet.create({ container: { + borderRadius: 8, overflow: "hidden" }, legend: { diff --git a/scripts/typecheck-doc-examples.mjs b/scripts/typecheck-doc-examples.mjs index bc3e8466..c75663b2 100644 --- a/scripts/typecheck-doc-examples.mjs +++ b/scripts/typecheck-doc-examples.mjs @@ -23,6 +23,7 @@ const publicMarkdownRoots = [ ]; const checkedFenceLanguages = new Set(["ts", "tsx"]); const chartKitNames = [ + "AreaChart", "BarChart", "ChartKitProvider", "ContributionGraph", @@ -37,12 +38,19 @@ const chartKitNames = [ "getLineChartAccessibilitySummary", "getLineChartDataTable" ]; +const chartKitProNames = [ + "CandlebarChart", + "CandlestickChart", + "ComboChart", + "RadarChart" +]; const reactNames = ["useMemo", "useState"]; const reactNativeNames = ["Pressable", "Text", "View"]; const ambientComponents = ["App", "Dashboard", "PortfolioHeader", "Root"]; const ambientArrays = [ "acquisitionShare", "benchmarkData", + "benchmarks", "candles", "data", "largeData", @@ -227,6 +235,10 @@ const buildExampleSource = ({ code, relativePath, line }) => { "react-native-chart-kit/v2", getMissingImports(code, importedNames, chartKitNames) ), + buildImportLine( + "@chart-kit/pro", + getMissingImports(code, importedNames, chartKitProNames) + ), buildImportLine( "react", getMissingImports(code, importedNames, reactNames) @@ -276,7 +288,8 @@ const parseTsConfig = () => { paths: { ...(parsed.options.paths ?? {}), "react-native-chart-kit": ["src/index.ts"], - "react-native-chart-kit/v2": ["packages/react-native/src/index.ts"] + "react-native-chart-kit/v2": ["packages/react-native/src/index.ts"], + "@chart-kit/pro": ["apps/site/src/previews/proStub.tsx"] }, skipLibCheck: true, strict: false