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 ( +
+
+ + {/* ── FRONT FACE ── */} +
+ {/* Info toggle button */} + + + +
+ + {/* ── BACK FACE ── */} +
+ {/* Close button */} + + + {/* Chart type badge */} +
+ 📊 + {chartType} +
+ + {/* Chart title */} +
{displayTitle}
+ + {/* Divider */} +
+ + {/* What the chart conveys */} +
What this chart shows
+

{description}

+ + {/* Divider */} +
+ + {/* Data insights */} +
Key insights to look for
+ {chartInsights.map((insight, i) => ( +
+
+ {insight} +
+ ))} +
+ +
+
+ ); +} + +// ─── ChartPanel ──────────────────────────────────────────────────────────── + function ChartPanel({ result, PlotComponent }) { if (!PlotComponent) { return ( @@ -146,74 +394,23 @@ function ChartPanel({ result, PlotComponent }) { }; return ( -
- -
+ chartKey={key} + fig={fig} + idx={idx} + isMatrix={isMatrix} + isWide={isWide} + gridSpan={gridSpan} + chartHeight={chartHeight} + normalizedData={normalizedData} + effectiveShowLegend={effectiveShowLegend} + margin={margin} + legend={legend} + hasLongLabels={hasLongLabels} + PlotComponent={PlotComponent} + insights={result?.insights || {}} + /> ); })}
diff --git a/frontend/src/index.css b/frontend/src/index.css index 25e4119..98407e8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -393,3 +393,162 @@ p, body { font-size: var(--font-body); font-weight: 400; color: var(--text-muted ::-webkit-scrollbar-track { background: var(--bg-deep); } ::-webkit-scrollbar-thumb { background: rgba(99,102,241,0.3); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: rgba(99,102,241,0.6); } + +/* ========================================================================= + CHART FLIP CARD + ========================================================================= */ + +.chart-flip-wrapper { + perspective: 1400px; + position: relative; +} + +.chart-flip-inner { + position: relative; + width: 100%; + height: 100%; + transition: transform 0.65s cubic-bezier(0.4, 0.2, 0.2, 1); + transform-style: preserve-3d; +} + +.chart-flip-wrapper.flipped .chart-flip-inner { + transform: rotateY(180deg); +} + +.chart-flip-front, +.chart-flip-back { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + border-radius: 14px; + overflow: hidden; +} + +.chart-flip-front { + background: rgba(13, 18, 32, 0.7); + backdrop-filter: blur(8px); + border: 1px solid var(--border-subtle); + box-shadow: var(--shadow-card); +} + +.chart-flip-back { + transform: rotateY(180deg); + background: linear-gradient(135deg, #0d1220 0%, #12192e 55%, #0d1631 100%); + border: 1px solid rgba(99, 102, 241, 0.35); + box-shadow: 0 0 40px rgba(99, 102, 241, 0.15), var(--shadow-card); + padding: 28px 26px 24px; + display: flex; + flex-direction: column; + gap: 0; + overflow-y: auto; +} + +/* Info / Close Toggle Button */ +.chart-info-btn { + position: absolute; + top: 12px; + right: 12px; + z-index: 10; + width: 30px; + height: 30px; + border-radius: 50%; + border: 1px solid rgba(99, 102, 241, 0.5); + background: rgba(99, 102, 241, 0.12); + color: #818cf8; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; + line-height: 1; + backdrop-filter: blur(6px); +} + +.chart-info-btn:hover { + background: rgba(99, 102, 241, 0.28); + border-color: var(--primary-500); + box-shadow: 0 0 14px rgba(99, 102, 241, 0.45); + transform: scale(1.12); +} + +/* Back face content */ +.chart-back-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 20px; + border: 1px solid rgba(99, 102, 241, 0.35); + background: rgba(99, 102, 241, 0.12); + color: #818cf8; + font-size: 11px; + font-family: 'Outfit', monospace; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 16px; + width: fit-content; +} + +.chart-back-title { + font-family: 'Syne', sans-serif; + font-size: 17px; + font-weight: 800; + color: var(--text-main); + margin-bottom: 10px; + line-height: 1.3; +} + +.chart-back-section-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--primary-500); + margin-bottom: 6px; + margin-top: 16px; +} + +.chart-back-description { + font-size: 13.5px; + color: #cbd5e1; + line-height: 1.65; +} + +.chart-back-insight-item { + display: flex; + gap: 10px; + align-items: flex-start; + font-size: 13px; + color: var(--text-muted); + line-height: 1.55; + margin-bottom: 8px; +} + +.chart-back-insight-dot { + width: 6px; + height: 6px; + min-width: 6px; + border-radius: 50%; + background: var(--primary-500); + box-shadow: 0 0 8px rgba(99, 102, 241, 0.7); + margin-top: 5px; +} + +.chart-back-divider { + width: 100%; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(99,102,241,0.25), transparent); + margin: 14px 0 0; +} + +@keyframes flipIn { + from { opacity: 0; transform: scale(0.97); } + to { opacity: 1; transform: scale(1); } +} +