diff --git a/backend/models/schemas.py b/backend/models/schemas.py index ea39be8..8a74c53 100644 --- a/backend/models/schemas.py +++ b/backend/models/schemas.py @@ -29,7 +29,7 @@ def password_not_blank(cls, v: str) -> str: @classmethod def email_must_be_deliverable(cls, v: str) -> str: try: - info = validate_email(str(v).strip(), check_deliverability=True) + info = validate_email(str(v).strip(), check_deliverability=False) return info.normalized.lower() except EmailNotValidError as exc: raise ValueError(str(exc)) from exc diff --git a/frontend/src/datapulse_dashboard.jsx b/frontend/src/datapulse_dashboard.jsx index a1f9165..5862050 100644 --- a/frontend/src/datapulse_dashboard.jsx +++ b/frontend/src/datapulse_dashboard.jsx @@ -99,6 +99,254 @@ function shouldHideLegend(data, traceCount) { return false; } +// ─── Chart info helpers ──────────────────────────────────────────────────── + +function inferChartType(key, traces) { + const k = key.toLowerCase(); + const primaryType = (traces[0]?.type || "").toLowerCase(); + + if (k.includes("scatter_matrix") || k.includes("splom")) return "Scatter Matrix"; + if (k.includes("heatmap") || primaryType === "heatmap") return "Heatmap"; + if (k.includes("timeseries") || k.includes("time_series")) return "Time Series"; + if (k.includes("line") || primaryType === "scatter") return "Line Chart"; + if (k.includes("bar") || primaryType === "bar") return "Bar Chart"; + if (k.includes("pie") || primaryType === "pie") return "Pie Chart"; + if (k.includes("donut") || (primaryType === "pie" && traces[0]?.hole > 0)) return "Donut Chart"; + if (k.includes("histogram") || primaryType === "histogram") return "Histogram"; + if (k.includes("box") || primaryType === "box") return "Box Plot"; + if (k.includes("violin") || primaryType === "violin") return "Violin Plot"; + if (k.includes("scatter") || primaryType === "scatter") return "Scatter Plot"; + if (k.includes("funnel") || primaryType === "funnel") return "Funnel Chart"; + if (k.includes("treemap") || primaryType === "treemap") return "Treemap"; + if (k.includes("sunburst") || primaryType === "sunburst") return "Sunburst Chart"; + if (primaryType) return primaryType.charAt(0).toUpperCase() + primaryType.slice(1) + " Chart"; + return "Chart"; +} + +const CHART_TYPE_DESCRIPTIONS = { + "Scatter Matrix": "Displays pairwise relationships between multiple numeric variables simultaneously, making it easy to spot correlations and clusters across the full dataset.", + "Heatmap": "Uses color intensity to encode values across a two-dimensional grid, revealing patterns, correlations, or concentrations at a glance.", + "Time Series": "Plots data points over a continuous time axis to reveal trends, seasonality, anomalies, and long-term momentum in time-ordered data.", + "Line Chart": "Connects data points with lines to illustrate trends and changes across a continuous axis, ideal for comparing trajectories over time or categories.", + "Bar Chart": "Compares discrete categories using rectangular bars, making it straightforward to rank, compare magnitudes, or track grouped values.", + "Pie Chart": "Shows the proportional share of each category as a slice of the whole, emphasizing part-to-whole relationships.", + "Donut Chart": "Like a pie chart but with a hollow center — ideal for highlighting individual segment proportions while keeping the design clean.", + "Histogram": "Groups continuous numeric values into bins to reveal the underlying frequency distribution, shape, spread, and skewness of the data.", + "Box Plot": "Summarises the distribution of a variable via its median, quartiles, and outliers, letting you compare spread and skewness across groups.", + "Violin Plot": "Combines a box plot with a probability density curve to show the full distribution shape, including multi-modality.", + "Scatter Plot": "Plots individual data points on two axes to reveal correlations, clusters, and outliers between two numeric variables.", + "Funnel Chart": "Tracks the progressive reduction of data through stages, commonly used for conversion or pipeline analysis.", + "Treemap": "Encodes hierarchical data as nested rectangles sized by value, making it easy to compare proportions within categories.", + "Sunburst Chart": "Shows hierarchical data as concentric rings, enabling drill-down into multi-level category relationships.", +}; + +function getChartDescription(chartType) { + return CHART_TYPE_DESCRIPTIONS[chartType] || "Visualises patterns and relationships present in the dataset."; +} + +function deriveChartInsights(key, fig, chartType) { + const traces = Array.isArray(fig.data) ? fig.data : []; + const layout = fig.layout || {}; + const titleRaw = typeof layout.title === "string" ? layout.title : (layout.title?.text || ""); + const title = cleanQuestionLabel(titleRaw) || key.replaceAll("_", " "); + const insights = []; + + // Number of series / groups + const seriesCount = traces.length; + if (seriesCount > 1) { + insights.push(`Compares ${seriesCount} data series — look for the series with the highest values or most distinct trend.`); + } + + // Axis labels as context clues + const xTitle = layout.xaxis?.title?.text || layout.xaxis?.title || ""; + const yTitle = layout.yaxis?.title?.text || layout.yaxis?.title || ""; + if (xTitle && yTitle) { + insights.push(`The X-axis represents "${cleanAxisTitle(xTitle)}" and the Y-axis represents "${cleanAxisTitle(yTitle)}" — focus on extreme values at either end.`); + } else if (xTitle) { + insights.push(`The horizontal axis shows "${cleanAxisTitle(xTitle)}" — scan for the tallest or most prominent category.`); + } + + // Chart-type specific insights + if (chartType === "Heatmap" || chartType === "Scatter Matrix") { + insights.push("Cells with intense color indicate strong relationships. Look for diagonal symmetry in correlation heatmaps."); + insights.push("Clusters of similar color suggest groups of correlated or co-occurring variables."); + } else if (chartType === "Time Series" || chartType === "Line Chart") { + insights.push("Follow the slope — a rising line signals growth while a falling line signals decline."); + insights.push("Sharp peaks or troughs mark anomalies or events worth investigating."); + } else if (chartType === "Bar Chart") { + insights.push("The tallest bar represents the dominant category — compare it against the average to assess how much it stands out."); + insights.push("Look for bars significantly shorter than the rest, as they may indicate underperforming segments."); + } else if (chartType === "Pie Chart" || chartType === "Donut Chart") { + insights.push("The largest slice dominates — consider whether a single category is disproportionately large."); + insights.push("Very thin slices may be candidates to group into an 'Other' category for clarity."); + } else if (chartType === "Histogram") { + insights.push("The peak of the histogram (mode) shows the most common value range in the dataset."); + insights.push("A long tail to the right or left indicates skewness and the presence of outliers."); + } else if (chartType === "Box Plot" || chartType === "Violin Plot") { + insights.push("Points plotted beyond the whiskers are statistical outliers — investigate them individually."); + insights.push("A wide interquartile range (IQR) indicates high variability within the group."); + } else if (chartType === "Scatter Plot") { + insights.push("A clear diagonal pattern suggests a strong correlation between the two variables."); + insights.push("Isolated points far from the main cluster are potential outliers worth reviewing."); + } + + // Fallback if nothing added + if (insights.length === 0) { + insights.push(`This chart visualises "${title}" — focus on the peaks, clusters, or segments that stand out most.`); + insights.push("Compare values across categories or time points to identify trends and exceptions."); + } + + return insights.slice(0, 4); // Cap at 4 bullet points +} + +// ─── Single flippable chart card ─────────────────────────────────────────── + +function ChartFlipCard({ + chartKey, fig, idx, isMatrix, isWide, gridSpan, chartHeight, + normalizedData, effectiveShowLegend, margin, legend, hasLongLabels, + PlotComponent, insights, +}) { + const [flipped, setFlipped] = useState(false); + const traces = Array.isArray(fig.data) ? fig.data : []; + const layout = fig.layout || {}; + + const chartType = inferChartType(chartKey, traces); + const description = getChartDescription(chartType); + const chartInsights = deriveChartInsights(chartKey, fig, chartType); + const titleRaw = typeof layout.title === "string" ? layout.title : (layout.title?.text || ""); + const displayTitle = truncateLabel(cleanQuestionLabel(titleRaw) || chartKey.replaceAll("_", " "), 80); + + // back face min-height must match the rendered chart height + const containerHeight = chartHeight + 48; // 24px padding top + bottom + + return ( +
{description}
+ + {/* Divider */} + + + {/* Data insights */} +