diff --git a/components/cohort-analysis-chart/README.md b/components/cohort-analysis-chart/README.md new file mode 100644 index 0000000..b62a02b --- /dev/null +++ b/components/cohort-analysis-chart/README.md @@ -0,0 +1,106 @@ +# Create a downloadable README.md file for the Cohort Analysis Chart + +content = """# 📊 Cohort Analysis Chart + +An interactive, data-driven cohort heatmap component built with React and Retool Custom Components. +It automatically detects dimensions and metrics from your dataset and renders a responsive cohort table with dynamic heatmap visualization and cell-level insights. + +--- + +## ✨ Features + +- Automatic field detection (X, Y, Value) +- Manual field override +- Heatmap visualization with intensity scaling +- Cohort table layout (rows = cohorts, columns = time) +- Interactive cells with selection panel +- Fully customizable UI (colors, fonts, layout) +- Max column control (1–12) +- Optimized performance using React hooks +- Seamless Retool integration + +--- + +## 🏗️ Tech Stack + +- React +- TypeScript +- Retool Custom Component API + +--- + +## 📦 Installation + +npm install @tryretool/custom-component-support + +--- + +## 🚀 Usage + +### Import Component + +import CohortAnalysisChart from "./CohortAnalysisChart"; + +--- + +## 📊 Sample Data + +[ + { "cohort": "Jan 2024", "month": 1, "retention": 100 }, + { "cohort": "Jan 2024", "month": 2, "retention": 78 }, + { "cohort": "Jan 2024", "month": 3, "retention": 65 }, + { "cohort": "Jan 2024", "month": 4, "retention": 52 }, + + { "cohort": "Feb 2024", "month": 1, "retention": 100 }, + { "cohort": "Feb 2024", "month": 2, "retention": 72 }, + { "cohort": "Feb 2024", "month": 3, "retention": 60 }, + { "cohort": "Feb 2024", "month": 4, "retention": 48 }, + + { "cohort": "Mar 2024", "month": 1, "retention": 100 }, + { "cohort": "Mar 2024", "month": 2, "retention": 70 }, + { "cohort": "Mar 2024", "month": 3, "retention": 58 }, + { "cohort": "Mar 2024", "month": 4, "retention": 45 } +] + +--- + +## ▶️ Render + + + +--- + +## 🧠 Smart Detection + +- Y-axis → cohort (e.g., Jan 2024) +- X-axis → time (month/week) +- Value → numeric metric (retention, revenue) + +--- + +## 📊 Visualization + +- Heatmap grid +- Color intensity based on values +- Handles missing values gracefully + +--- + +## ⚠️ Notes + +- Requires Retool environment +- Data must be an array of objects +- Only flat data supported + +--- + +## 📄 License + +MIT License +""" + +file_path = "/mnt/data/Cohort_Analysis_Chart_README.md" +with open(file_path, "w") as f: + f.write(content) + +file_path \ No newline at end of file diff --git a/components/cohort-analysis-chart/cover.png b/components/cohort-analysis-chart/cover.png new file mode 100644 index 0000000..6be2d63 Binary files /dev/null and b/components/cohort-analysis-chart/cover.png differ diff --git a/components/cohort-analysis-chart/metadata.json b/components/cohort-analysis-chart/metadata.json new file mode 100644 index 0000000..3218d4e --- /dev/null +++ b/components/cohort-analysis-chart/metadata.json @@ -0,0 +1,17 @@ +{ + "id": "cohort-analysis-chart", + "title": "Cohort Analysis Chart", + "author": "@widlestudiollp", + "shortDescription": "An interactive cohort heatmap that auto-detects data fields and visualizes retention and trends with dynamic intensity scaling.", + "tags": [ + "Charts", + "React", + "Data Visualization", + "Analytics", + "Dashboard", + "Heatmap", + "Cohort Analysis", + "Interactive", + "Custom" + ] +} \ No newline at end of file diff --git a/components/cohort-analysis-chart/package.json b/components/cohort-analysis-chart/package.json new file mode 100644 index 0000000..f0104f7 --- /dev/null +++ b/components/cohort-analysis-chart/package.json @@ -0,0 +1,47 @@ +{ + "name": "my-react-app", + "version": "0.1.0", + "private": true, + "dependencies": { + "@tryretool/custom-component-support": "latest", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "dev": "npx retool-ccl dev", + "deploy": "npx retool-ccl deploy", + "test": "vitest" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@typescript-eslint/eslint-plugin": "^7.3.1", + "@typescript-eslint/parser": "^7.3.1", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.1", + "postcss-modules": "^6.0.0", + "prettier": "^3.0.3", + "vitest": "^4.0.17" + }, + "retoolCustomComponentLibraryConfig": { + "name": "CohortAnalysisChart", + "label": "Cohort Analysis Chart", + "description": "Dynamic Chart", + "entryPoint": "src/index.tsx", + "outputPath": "dist" + } +} \ No newline at end of file diff --git a/components/cohort-analysis-chart/src/components/cohorts.tsx b/components/cohort-analysis-chart/src/components/cohorts.tsx new file mode 100644 index 0000000..213b394 --- /dev/null +++ b/components/cohort-analysis-chart/src/components/cohorts.tsx @@ -0,0 +1,1172 @@ +import React, { FC, useEffect, useMemo } from "react"; +import { Retool } from "@tryretool/custom-component-support"; + +type GenericRow = Record; + +type CohortCell = { + raw: GenericRow; + value: number | null; +}; + +type FieldMeta = { + allFields: string[]; + numericFields: string[]; + dimensionFields: string[]; +}; + +type MaxColumnsOption = + | "one" + | "two" + | "three" + | "four" + | "five" + | "six" + | "seven" + | "eight" + | "nine" + | "ten" + | "eleven" + | "twelve"; + +type FontFamilyOption = + | "inter" + | "roboto" + | "openSans" + | "lato" + | "poppins" + | "montserrat" + | "playfairDisplay"; + +function clampNumber(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function maxColumnsFromOption(option: MaxColumnsOption): number { + switch (option) { + case "one": + return 1; + case "two": + return 2; + case "three": + return 3; + case "four": + return 4; + case "five": + return 5; + case "six": + return 6; + case "seven": + return 7; + case "eight": + return 8; + case "nine": + return 9; + case "ten": + return 10; + case "eleven": + return 11; + case "twelve": + return 12; + default: + return 12; + } +} + +function fontFamilyFromOption(option: FontFamilyOption): string { + switch (option) { + case "inter": + return "'Inter', sans-serif"; + case "roboto": + return "'Roboto', sans-serif"; + case "openSans": + return "'Open Sans', sans-serif"; + case "lato": + return "'Lato', sans-serif"; + case "poppins": + return "'Poppins', sans-serif"; + case "montserrat": + return "'Montserrat', sans-serif"; + case "playfairDisplay": + return "'Playfair Display', serif"; + default: + return "'Inter', sans-serif"; + } +} + +function toStartCaseLabel(value: string): string { + if (!value) return ""; + return value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +export const CohortAnalysisChart: FC = () => { + Retool.useComponentSettings({ + defaultHeight: 28, + defaultWidth: 8, + }); + + const [data] = Retool.useStateArray({ + name: "data", + initialValue: [], + inspector: "text", + label: "Data", + description: "Array of objects used to generate the cohort heatmap.", + }); + + const [selectedXAxisField, setSelectedXAxisField] = Retool.useStateString({ + name: "selectedXAxisField", + initialValue: "", + inspector: "text", + label: "X axis field", + description: "Field used for cohort columns. Leave empty to auto-detect from data.", + }); + + const [selectedYAxisField, setSelectedYAxisField] = Retool.useStateString({ + name: "selectedYAxisField", + initialValue: "", + inspector: "text", + label: "Y axis field", + description: "Field used for cohort rows. Leave empty to auto-detect from data.", + }); + + const [selectedValueField, setSelectedValueField] = Retool.useStateString({ + name: "selectedValueField", + initialValue: "", + inspector: "text", + label: "Value field", + description: "Numeric field used for the heatmap cell values. Leave empty to auto-detect.", + }); + + const [title] = Retool.useStateString({ + name: "title", + initialValue: "Cohort Analysis", + inspector: "text", + label: "Title", + description: "Main heading displayed above the cohort chart.", + }); + + const [subtitle] = Retool.useStateString({ + name: "subtitle", + initialValue: "", + inspector: "text", + label: "Subtitle", + description: "Optional helper text displayed below the title.", + }); + + const [showDetectedKeys] = Retool.useStateBoolean({ + name: "showDetectedKeys", + initialValue: true, + inspector: "checkbox", + label: "Show detected keys", + description: "Shows available fields and resolved field mappings for debugging or setup.", + }); + + const [showCellValues] = Retool.useStateBoolean({ + name: "showCellValues", + initialValue: true, + inspector: "checkbox", + label: "Show cell values", + description: "Controls whether the value is shown inside each heatmap cell.", + }); + + const [maxColumnsSelect] = Retool.useStateEnumeration< + [ + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve" + ] + >({ + name: "maxColumnsSelect", + initialValue: "twelve", + enumDefinition: [ + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + ], + enumLabels: { + one: "1", + two: "2", + three: "3", + four: "4", + five: "5", + six: "6", + seven: "7", + eight: "8", + nine: "9", + ten: "10", + eleven: "11", + twelve: "12", + }, + inspector: "select", + label: "Max columns", + description: "Select the maximum number of cohort columns to display.", + }); + + const [roundDecimals] = Retool.useStateNumber({ + name: "roundDecimals", + initialValue: 1, + inspector: "text", + label: "Decimals", + description: "Maximum number of decimal places used when formatting cell values.", + }); + + const [fontFamilySelect] = Retool.useStateEnumeration< + ["inter", "roboto", "openSans", "lato", "poppins", "montserrat", "playfairDisplay"] + >({ + name: "fontFamilySelect", + initialValue: "inter", + enumDefinition: ["inter", "roboto", "openSans", "lato", "poppins", "montserrat", "playfairDisplay"], + enumLabels: { + inter: "Inter", + roboto: "Roboto", + openSans: "Open Sans", + lato: "Lato", + poppins: "Poppins", + montserrat: "Montserrat", + playfairDisplay: "Playfair Display", + }, + inspector: "select", + label: "Font family", + description: "Select a predefined font family for the component.", + }); + + const [titleColor] = Retool.useStateString({ + name: "titleColor", + initialValue: "#111827", + inspector: "text", + label: "Title color", + description: "Color used for the chart title and stronger heading text.", + }); + + const [mutedTextColor] = Retool.useStateString({ + name: "mutedTextColor", + initialValue: "#6b7280", + inspector: "text", + label: "Muted text color", + description: "Color used for helper text, subtitles, and lower emphasis text.", + }); + + const [backgroundColor] = Retool.useStateString({ + name: "backgroundColor", + initialValue: "#ffffff", + inspector: "text", + label: "Background color", + description: "Background color of the overall component container.", + }); + + const [tableHeaderBg] = Retool.useStateString({ + name: "tableHeaderBg", + initialValue: "#f9fafb", + inspector: "text", + label: "Header background", + description: "Background color used for the table header row.", + }); + + const [borderColor] = Retool.useStateString({ + name: "borderColor", + initialValue: "#e5e7eb", + inspector: "text", + label: "Border color", + description: "Border color used throughout the table and surrounding panels.", + }); + + const [emptyCellColor] = Retool.useStateString({ + name: "emptyCellColor", + initialValue: "#f3f4f6", + inspector: "text", + label: "Empty cell color", + description: "Background color used when a cohort cell has no value.", + }); + + const [heatmapBaseColor] = Retool.useStateString({ + name: "heatmapBaseColor", + initialValue: "#2563eb", + inspector: "text", + label: "Heatmap base color", + description: "Base color used to generate the heatmap intensity scale.", + }); + + const [cellTextLightColor] = Retool.useStateString({ + name: "cellTextLightColor", + initialValue: "#111827", + inspector: "text", + label: "Cell text light color", + description: "Text color used on lighter heatmap cells.", + }); + + const [cellTextDarkColor] = Retool.useStateString({ + name: "cellTextDarkColor", + initialValue: "#ffffff", + inspector: "text", + label: "Cell text dark color", + description: "Text color used on darker heatmap cells.", + }); + + const [rowLabelBg] = Retool.useStateString({ + name: "rowLabelBg", + initialValue: "#ffffff", + inspector: "text", + label: "Row label background", + description: "Background color used for the sticky row label column.", + }); + + const [selectedBorderColor] = Retool.useStateString({ + name: "selectedBorderColor", + initialValue: "#111827", + inspector: "text", + label: "Selected cell border", + description: "Border and highlight color used for the selected heatmap cell.", + }); + + const [selectedPanelBg] = Retool.useStateString({ + name: "selectedPanelBg", + initialValue: "#f8fafc", + inspector: "text", + label: "Selected panel bg", + description: "Background color of the selected cell summary panel.", + }); + + const [selectedPanelTextColor] = Retool.useStateString({ + name: "selectedPanelTextColor", + initialValue: "#111827", + inspector: "text", + label: "Selected panel text", + description: "Text color used inside the selected cell summary panel.", + }); + + const [validationMessage, setValidationMessage] = Retool.useStateString({ + name: "validationMessage", + initialValue: "", + inspector: "hidden", + label: "Validation message", + }); + + const [resolvedConfig, setResolvedConfig] = Retool.useStateObject({ + name: "resolvedConfig", + initialValue: {}, + inspector: "hidden", + label: "Resolved config", + }); + + const [selectedCell, setSelectedCell] = Retool.useStateObject({ + name: "selectedCell", + initialValue: {}, + inspector: "hidden", + label: "Selected cell", + }); + + const [selectedValue, setSelectedValue] = Retool.useStateString({ + name: "selectedValue", + initialValue: "", + inspector: "hidden", + label: "Selected value", + }); + + const safeRows: GenericRow[] = useMemo(() => { + return Array.isArray(data) + ? (data.filter((row) => row && typeof row === "object") as GenericRow[]) + : []; + }, [data]); + + const fieldMeta: FieldMeta = useMemo(() => { + const keyOrder: string[] = []; + const keySet = new Set(); + const numeric = new Set(); + const dimension = new Set(); + + for (const row of safeRows) { + for (const key of Object.keys(row)) { + if (!keySet.has(key)) { + keySet.add(key); + keyOrder.push(key); + } + } + + for (const [key, value] of Object.entries(row)) { + if (typeof value === "number" && Number.isFinite(value)) { + numeric.add(key); + dimension.add(key); + } else if (typeof value === "string" || typeof value === "boolean") { + dimension.add(key); + } + } + } + + return { + allFields: keyOrder, + numericFields: keyOrder.filter((k) => numeric.has(k)), + dimensionFields: keyOrder.filter((k) => dimension.has(k)), + }; + }, [safeRows]); + + const autoDetectedFields = useMemo(() => { + const allFields = fieldMeta.allFields; + const numericFields = fieldMeta.numericFields; + const dimensionFields = fieldMeta.dimensionFields; + + const nonNumericDimensionFields = dimensionFields.filter( + (field) => !numericFields.includes(field) + ); + + const detectedValue = numericFields[0] || ""; + const detectedY = + nonNumericDimensionFields[0] || + dimensionFields[0] || + allFields[0] || + ""; + const detectedX = + allFields.find((field) => field !== detectedY && field !== detectedValue) || + allFields.find((field) => field !== detectedY) || + allFields[1] || + ""; + + return { + detectedX, + detectedY, + detectedValue, + }; + }, [fieldMeta]); + + useEffect(() => { + if (!selectedYAxisField && autoDetectedFields.detectedY) { + setSelectedYAxisField(autoDetectedFields.detectedY); + } + }, [selectedYAxisField, autoDetectedFields.detectedY, setSelectedYAxisField]); + + useEffect(() => { + if (!selectedXAxisField && autoDetectedFields.detectedX) { + setSelectedXAxisField(autoDetectedFields.detectedX); + } + }, [selectedXAxisField, autoDetectedFields.detectedX, setSelectedXAxisField]); + + useEffect(() => { + if (!selectedValueField && autoDetectedFields.detectedValue) { + setSelectedValueField(autoDetectedFields.detectedValue); + } + }, [selectedValueField, autoDetectedFields.detectedValue, setSelectedValueField]); + + useEffect(() => { + if (selectedXAxisField && !fieldMeta.allFields.includes(selectedXAxisField)) { + setSelectedXAxisField(autoDetectedFields.detectedX || ""); + } + }, [selectedXAxisField, fieldMeta.allFields, autoDetectedFields.detectedX, setSelectedXAxisField]); + + useEffect(() => { + if (selectedYAxisField && !fieldMeta.allFields.includes(selectedYAxisField)) { + setSelectedYAxisField(autoDetectedFields.detectedY || ""); + } + }, [selectedYAxisField, fieldMeta.allFields, autoDetectedFields.detectedY, setSelectedYAxisField]); + + useEffect(() => { + if (selectedValueField && !fieldMeta.numericFields.includes(selectedValueField)) { + setSelectedValueField(autoDetectedFields.detectedValue || ""); + } + }, [selectedValueField, fieldMeta.numericFields, autoDetectedFields.detectedValue, setSelectedValueField]); + + const resolveAxisFields = ( + meta: FieldMeta, + rawX: string, + rawY: string, + rawValue: string, + autoX: string, + autoY: string, + autoValue: string + ) => { + let resolvedX = rawX || autoX; + let resolvedY = rawY || autoY; + let resolvedValue = rawValue || autoValue; + + const numericFields = meta.numericFields; + const dimensionFields = meta.dimensionFields; + const allFields = meta.allFields; + + if (!resolvedY || !allFields.includes(resolvedY)) resolvedY = autoY; + if (!resolvedX || !allFields.includes(resolvedX)) resolvedX = autoX; + if (!resolvedValue || !numericFields.includes(resolvedValue)) resolvedValue = autoValue; + + if (resolvedY === resolvedValue) { + resolvedY = + dimensionFields.find((f) => f !== resolvedValue) || + allFields.find((f) => f !== resolvedValue) || + ""; + } + + if (resolvedX === resolvedY || !resolvedX) { + resolvedX = + allFields.find((f) => f !== resolvedY && f !== resolvedValue) || + allFields.find((f) => f !== resolvedY) || + ""; + } + + if (resolvedValue === resolvedX || resolvedValue === resolvedY) { + resolvedValue = + numericFields.find((f) => f !== resolvedX && f !== resolvedY) || ""; + } + + if (resolvedX === resolvedY) { + const alternativeY = + allFields.find((f) => f !== resolvedX && f !== resolvedValue) || ""; + if (alternativeY) resolvedY = alternativeY; + } + + return { resolvedX, resolvedY, resolvedValue }; + }; + + const resolvedFields = useMemo(() => { + return resolveAxisFields( + fieldMeta, + selectedXAxisField, + selectedYAxisField, + selectedValueField, + autoDetectedFields.detectedX, + autoDetectedFields.detectedY, + autoDetectedFields.detectedValue + ); + }, [ + fieldMeta, + selectedXAxisField, + selectedYAxisField, + selectedValueField, + autoDetectedFields, + ]); + + const resolvedXAxisField = resolvedFields.resolvedX; + const resolvedYAxisField = resolvedFields.resolvedY; + const resolvedValueField = resolvedFields.resolvedValue; + + const normalizedMaxColumns = useMemo(() => { + return maxColumnsFromOption(maxColumnsSelect); + }, [maxColumnsSelect]); + + const safeRoundDecimals = useMemo(() => { + if (!Number.isFinite(roundDecimals)) return 1; + return clampNumber(Math.round(roundDecimals), 0, 6); + }, [roundDecimals]); + + const resolvedFontFamily = useMemo(() => { + return fontFamilyFromOption(fontFamilySelect); + }, [fontFamilySelect]); + + const validation = useMemo(() => { + if (!Array.isArray(data)) { + return { ok: false, message: "Data must be an array of objects." }; + } + + if (safeRows.length === 0) { + return { ok: false, message: "No rows found. Pass an array of objects." }; + } + + if (!resolvedXAxisField) { + return { ok: false, message: "Could not resolve X axis field." }; + } + + if (!resolvedYAxisField) { + return { ok: false, message: "Could not resolve Y axis field." }; + } + + if (!resolvedValueField) { + return { ok: false, message: "Could not resolve value field." }; + } + + if (resolvedXAxisField === resolvedYAxisField) { + return { + ok: false, + message: "Could not resolve different X and Y axis fields from the data.", + }; + } + + if (!fieldMeta.allFields.includes(resolvedXAxisField)) { + return { + ok: false, + message: `X axis field "${resolvedXAxisField}" is not present in the data.`, + }; + } + + if (!fieldMeta.allFields.includes(resolvedYAxisField)) { + return { + ok: false, + message: `Y axis field "${resolvedYAxisField}" is not present in the data.`, + }; + } + + if (!fieldMeta.numericFields.includes(resolvedValueField)) { + return { + ok: false, + message: `Value field "${resolvedValueField}" is not present as a numeric field in the data.`, + }; + } + + return { ok: true, message: "" }; + }, [ + data, + safeRows, + resolvedXAxisField, + resolvedYAxisField, + resolvedValueField, + fieldMeta, + ]); + + useEffect(() => { + setValidationMessage(validation.message); + }, [validation.message, setValidationMessage]); + + useEffect(() => { + setResolvedConfig({ + selectedXAxisField, + selectedYAxisField, + selectedValueField, + resolvedXAxisField, + resolvedYAxisField, + resolvedValueField, + availableFields: fieldMeta.allFields, + numericFields: fieldMeta.numericFields, + dimensionFields: fieldMeta.dimensionFields, + autoDetectedFields, + maxColumnsSelect, + normalizedMaxColumns, + roundDecimals: safeRoundDecimals, + fontFamilySelect, + resolvedFontFamily, + }); + }, [ + selectedXAxisField, + selectedYAxisField, + selectedValueField, + resolvedXAxisField, + resolvedYAxisField, + resolvedValueField, + fieldMeta, + autoDetectedFields, + maxColumnsSelect, + normalizedMaxColumns, + safeRoundDecimals, + fontFamilySelect, + resolvedFontFamily, + setResolvedConfig, + ]); + + const chartData = useMemo(() => { + if (!validation.ok) { + return { + xValues: [] as (string | number)[], + yValues: [] as (string | number)[], + matrix: {} as Record>, + maxValue: 0, + validRowCount: 0, + }; + } + + const xValueSet = new Set(); + const yValueSet = new Set(); + const matrix: Record> = {}; + let maxValue = 0; + let validRowCount = 0; + + for (const row of safeRows) { + const xRaw = row[resolvedXAxisField]; + const yRaw = row[resolvedYAxisField]; + const vRaw = row[resolvedValueField]; + + if (xRaw == null || yRaw == null || vRaw == null) continue; + if (!(typeof vRaw === "number" && Number.isFinite(vRaw))) continue; + + validRowCount += 1; + + const xKey = String(xRaw); + const yKey = String(yRaw); + + xValueSet.add(xRaw as string | number); + yValueSet.add(yRaw as string | number); + + if (!matrix[yKey]) matrix[yKey] = {}; + matrix[yKey][xKey] = { + raw: row, + value: vRaw, + }; + + if (vRaw > maxValue) maxValue = vRaw; + } + + const sortMixed = (a: string | number, b: string | number) => { + const aNum = Number(a); + const bNum = Number(b); + const aIsNum = !Number.isNaN(aNum); + const bIsNum = !Number.isNaN(bNum); + + if (aIsNum && bIsNum) return aNum - bNum; + return String(a).localeCompare(String(b)); + }; + + return { + xValues: [...xValueSet].sort(sortMixed).slice(0, normalizedMaxColumns), + yValues: [...yValueSet].sort(sortMixed), + matrix, + maxValue, + validRowCount, + }; + }, [ + validation.ok, + safeRows, + resolvedXAxisField, + resolvedYAxisField, + resolvedValueField, + normalizedMaxColumns, + ]); + + const hasRenderableData = + validation.ok && + chartData.validRowCount > 0 && + chartData.xValues.length > 0 && + chartData.yValues.length > 0; + + const selectedCellKey = useMemo(() => { + if (!selectedCell || typeof selectedCell !== "object") return ""; + const xValue = (selectedCell as Record).xValue; + const yValue = (selectedCell as Record).yValue; + if (xValue == null || yValue == null) return ""; + return `${String(yValue)}__${String(xValue)}`; + }, [selectedCell]); + + const parseHexToRgb = (hex: string): [number, number, number] => { + const clean = hex.trim().replace("#", ""); + if (!/^[0-9a-fA-F]{3,8}$/.test(clean)) return [37, 99, 235]; + + let r = 37; + let g = 99; + let b = 235; + + if (clean.length === 3) { + r = parseInt(clean[0] + clean[0], 16); + g = parseInt(clean[1] + clean[1], 16); + b = parseInt(clean[2] + clean[2], 16); + } else { + r = parseInt(clean.slice(0, 2), 16); + g = parseInt(clean.slice(2, 4), 16); + b = parseInt(clean.slice(4, 6), 16); + } + + return [r, g, b]; + }; + + const getCellBackground = (value: number | null) => { + if (value == null || chartData.maxValue <= 0) return emptyCellColor; + const [r, g, b] = parseHexToRgb(heatmapBaseColor); + const ratio = value / chartData.maxValue; + const alpha = 0.18 + ratio * 0.82; + return `rgba(${r}, ${g}, ${b}, ${Math.min(alpha, 1)})`; + }; + + const getCellTextColor = (value: number | null) => { + if (value == null || chartData.maxValue <= 0) return mutedTextColor; + const ratio = value / chartData.maxValue; + return ratio >= 0.35 ? cellTextDarkColor : cellTextLightColor; + }; + + const getCellTextShadow = (value: number | null) => { + if (value == null || chartData.maxValue <= 0) return "none"; + const ratio = value / chartData.maxValue; + return ratio >= 0.35 ? "0 1px 1px rgba(0,0,0,0.18)" : "none"; + }; + + const formatValue = (value: number | null) => { + if (value == null) return "No data"; + return Number(value).toFixed(safeRoundDecimals); + }; + + const selectedCellSummary = useMemo(() => { + if (!selectedCell || typeof selectedCell !== "object") return null; + const cell = selectedCell as Record; + if (cell.xValue == null || cell.yValue == null) return null; + return { + xValue: cell.xValue, + yValue: cell.yValue, + value: cell.value, + }; + }, [selectedCell]); + + return ( +
+ + +
+
+
+ {title} +
+ + {subtitle ? ( +
{subtitle}
+ ) : null} +
+ + {showDetectedKeys ? ( +
+
+ Fields:{" "} + {fieldMeta.allFields.length + ? fieldMeta.allFields.map((field) => toStartCaseLabel(field)).join(", ") + : "None"} +
+
+ Numeric:{" "} + {fieldMeta.numericFields.length + ? fieldMeta.numericFields.map((field) => toStartCaseLabel(field)).join(", ") + : "None"} +
+
+ Resolved: Y = {toStartCaseLabel(resolvedYAxisField || "-")}, X ={" "} + {toStartCaseLabel(resolvedXAxisField || "-")}, Value = {toStartCaseLabel(resolvedValueField || "-")} +
+
+ Max columns: {normalizedMaxColumns} +
+
+ Font: {fontFamilySelect} +
+
+ ) : null} + + {!validation.ok ? ( +
+
+ Invalid data +
+
+ {validation.message} +
+
+ ) : !hasRenderableData ? ( +
+
+
+ No data +
+
+ Rows are present, but no valid values were found for the selected X, Y, and Value fields. +
+
+
+ ) : ( + <> + {selectedCellSummary ? ( +
+
Selected
+
+ {toStartCaseLabel(resolvedYAxisField)}: {String(selectedCellSummary.yValue)} +
+
+ {toStartCaseLabel(resolvedXAxisField)}: {String(selectedCellSummary.xValue)} +
+
+ {toStartCaseLabel(resolvedValueField)}: {formatValue(selectedCellSummary.value as number | null)} +
+
+ ) : null} + +
+ + + + + + {chartData.xValues.map((xVal) => ( + + ))} + + + + + {chartData.yValues.map((yVal) => { + const yKey = String(yVal); + + return ( + + + + {chartData.xValues.map((xVal) => { + const xKey = String(xVal); + const cell = chartData.matrix[yKey]?.[xKey]; + const value = cell?.value ?? null; + const key = `${yKey}__${xKey}`; + const isSelected = key === selectedCellKey; + + const tooltipText = + value == null + ? `${toStartCaseLabel(resolvedYAxisField)}: ${yKey}\n${toStartCaseLabel( + resolvedXAxisField + )}: ${xKey}\nNo data` + : `${toStartCaseLabel(resolvedYAxisField)}: ${yKey}\n${toStartCaseLabel( + resolvedXAxisField + )}: ${xKey}\n${toStartCaseLabel(resolvedValueField)}: ${formatValue(value)}`; + + return ( + + ); + })} + + ); + })} + +
+ {toStartCaseLabel(resolvedYAxisField)} + + {String(xVal)} +
+ {yKey} + { + const payload = { + xField: resolvedXAxisField, + yField: resolvedYAxisField, + valueField: resolvedValueField, + xValue: xVal, + yValue: yVal, + value, + row: cell?.raw ?? null, + }; + setSelectedCell(payload); + setSelectedValue(value == null ? "No data" : formatValue(value)); + }} + style={{ + padding: "14px 12px", + textAlign: "center", + verticalAlign: "middle", + borderBottom: `1px solid ${borderColor}`, + background: getCellBackground(value), + color: getCellTextColor(value), + fontSize: 15, + fontWeight: 700, + lineHeight: 1.2, + letterSpacing: "0.01em", + textShadow: getCellTextShadow(value), + cursor: "pointer", + outline: isSelected ? `2px solid ${selectedBorderColor}` : "none", + outlineOffset: isSelected ? "-2px" : undefined, + boxShadow: isSelected + ? `inset 0 0 0 2px ${selectedBorderColor}` + : "none", + fontFamily: "inherit", + }} + > + {showCellValues ? formatValue(value) : ""} +
+
+ +
+ Low +
+ High +
+ + )} +
+
+ ); +}; + +export default CohortAnalysisChart; \ No newline at end of file diff --git a/components/cohort-analysis-chart/src/index.tsx b/components/cohort-analysis-chart/src/index.tsx new file mode 100644 index 0000000..dd00342 --- /dev/null +++ b/components/cohort-analysis-chart/src/index.tsx @@ -0,0 +1 @@ +export { CohortAnalysisChart } from './components/cohorts';