diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5770aa5..dfb2b4b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,6 +41,7 @@ "d3": "^7.9.0", "d3-array": "^3.2.4", "date-fns": "^4.1.0", + "katex": "^0.16.44", "lucide-react": "^0.564.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -7575,6 +7576,31 @@ "node": ">=6" } }, + "node_modules/katex": { + "version": "0.16.44", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.44.tgz", + "integrity": "sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/lightningcss": { "version": "1.29.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index bc5258e..469a994 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,6 +45,7 @@ "d3": "^7.9.0", "d3-array": "^3.2.4", "date-fns": "^4.1.0", + "katex": "^0.16.44", "lucide-react": "^0.564.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/frontend/src/components/diagnostics/ensembleChart.test.ts b/frontend/src/components/diagnostics/ensembleChart.test.ts new file mode 100644 index 0000000..1d84f1c --- /dev/null +++ b/frontend/src/components/diagnostics/ensembleChart.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { KNOWN_CATEGORY_ORDERS, sortCategories } from "./ensembleChart"; + +type NamedItem = { name: string }; + +function items(...names: string[]): NamedItem[] { + return names.map((name) => ({ name })); +} + +function names(sorted: NamedItem[]): string[] { + return sorted.map((item) => item.name); +} + +describe("KNOWN_CATEGORY_ORDERS", () => { + it("includes Annual variants before seasons", () => { + const season = KNOWN_CATEGORY_ORDERS.season; + expect(season.indexOf("Annual")).toBeLessThan(season.indexOf("DJF")); + expect(season.indexOf("DJF")).toBeLessThan(season.indexOf("MAM")); + expect(season.indexOf("MAM")).toBeLessThan(season.indexOf("JJA")); + expect(season.indexOf("JJA")).toBeLessThan(season.indexOf("SON")); + }); +}); + +describe("sortCategories", () => { + it("sorts seasons chronologically via auto-detection", () => { + const input = items("SON", "DJF", "JJA", "MAM"); + const result = names(sortCategories(input)); + expect(result).toEqual(["DJF", "MAM", "JJA", "SON"]); + }); + + it("sorts Annual before seasons via auto-detection", () => { + const input = items("SON", "Annual", "DJF", "JJA", "MAM"); + const result = names(sortCategories(input)); + expect(result).toEqual(["Annual", "DJF", "MAM", "JJA", "SON"]); + }); + + it("handles lowercase annual variant", () => { + const input = items("SON", "annual", "DJF", "MAM", "JJA"); + const result = names(sortCategories(input)); + expect(result).toEqual(["annual", "DJF", "MAM", "JJA", "SON"]); + }); + + it("handles ANN variant", () => { + const input = items("SON", "ANN", "DJF", "MAM", "JJA"); + const result = names(sortCategories(input)); + expect(result).toEqual(["ANN", "DJF", "MAM", "JJA", "SON"]); + }); + + it("preserves original order for non-season data", () => { + const input = items("region_c", "region_a", "region_b"); + const result = names(sortCategories(input)); + expect(result).toEqual(["region_c", "region_a", "region_b"]); + }); + + it("uses explicit category order when provided", () => { + const input = items("c", "a", "b"); + const result = names(sortCategories(input, ["a", "b", "c"])); + expect(result).toEqual(["a", "b", "c"]); + }); + + it("puts items not in explicit order at the end", () => { + const input = items("c", "unknown", "a", "b"); + const result = names(sortCategories(input, ["a", "b", "c"])); + expect(result).toEqual(["a", "b", "c", "unknown"]); + }); + + it("returns empty array for empty input", () => { + expect(sortCategories([])).toEqual([]); + }); + + it("does not auto-detect when categories don't fully match a known order", () => { + // "Temperature" is not in any known order, so no sorting applied + const input = items("Temperature", "DJF", "MAM"); + const result = names(sortCategories(input)); + expect(result).toEqual(["Temperature", "DJF", "MAM"]); + }); +}); diff --git a/frontend/src/components/diagnostics/ensembleChart.tsx b/frontend/src/components/diagnostics/ensembleChart.tsx index d0d6f4f..37f7d6b 100644 --- a/frontend/src/components/diagnostics/ensembleChart.tsx +++ b/frontend/src/components/diagnostics/ensembleChart.tsx @@ -23,16 +23,16 @@ import useMousePositionAndWidth from "@/hooks/useMousePosition"; import { createScaledTickFormatter } from "../execution/values/series/utils"; // Well-known category orderings for common climate dimensions -const KNOWN_CATEGORY_ORDERS: Record = { - // Meteorological seasons - season: ["DJF", "MAM", "JJA", "SON"], +export const KNOWN_CATEGORY_ORDERS: Record = { + // Meteorological seasons (Annual first, then chronological) + season: ["Annual", "annual", "ANN", "DJF", "MAM", "JJA", "SON"], }; /** * Sort chart categories using a known ordering if one exists, * otherwise preserve the original order. */ -function sortCategories( +export function sortCategories( items: T[], categoryOrder?: string[], ): T[] { @@ -319,8 +319,8 @@ export const EnsembleChart = ({ const fmt = valueFormatter ?? createScaledTickFormatter(yDomain); - // Hide x-axis when there are too many items (threshold: 6) - const shouldShowXAxis = sortedChartData.length <= 6; + // Rotate x-axis labels when there are many items to keep them visible + const shouldRotateXAxis = sortedChartData.length > 6; // Adjust spacing based on number of items // make them closer together when there are many @@ -345,7 +345,14 @@ export const EnsembleChart = ({ tickLine={false} axisLine={{ stroke: "#E5E7EB" }} tickMargin={10} - hide={!shouldShowXAxis} + angle={shouldRotateXAxis ? -45 : 0} + textAnchor={shouldRotateXAxis ? "end" : "middle"} + height={shouldRotateXAxis ? 100 : 30} + interval={ + sortedChartData.length > 20 + ? Math.floor(sortedChartData.length / 20) + : 0 + } /> ) { + const hasLatex = long_name ? containsLatex(long_name) : false; + const captionHtml = useMemo( + () => (hasLatex ? renderLatexToHtml(long_name) : null), + [long_name, hasLatex], + ); + return (
{long_name} - {long_name} + {captionHtml ? ( + + ) : ( + + {long_name} + + )}
); } diff --git a/frontend/src/components/diagnostics/figureGallery.tsx b/frontend/src/components/diagnostics/figureGallery.tsx index 69965ab..2bfc298 100644 --- a/frontend/src/components/diagnostics/figureGallery.tsx +++ b/frontend/src/components/diagnostics/figureGallery.tsx @@ -1,8 +1,21 @@ import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { useWindowVirtualizer } from "@tanstack/react-virtual"; -import { Download, ExternalLink, MoreHorizontal } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ChevronLeft, + ChevronRight, + Download, + ExternalLink, + MoreHorizontal, +} from "lucide-react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import type { ExecutionGroup, ExecutionOutput } from "@/client"; import { diagnosticsListExecutionGroupsOptions } from "@/client/@tanstack/react-query.gen.ts"; import { Figure } from "@/components/diagnostics/figure.tsx"; @@ -26,6 +39,8 @@ import { interface DiagnosticFigureGalleryProps { providerSlug: string; diagnosticSlug: string; + /** When set, display figures in paginated mode instead of infinite scroll */ + pageSize?: number; } interface FigureWithGroup { @@ -37,7 +52,7 @@ const FigureDropDown = ({ figure, executionGroup }: FigureWithGroup) => { return ( - @@ -62,9 +77,77 @@ const FigureDropDown = ({ figure, executionGroup }: FigureWithGroup) => { ); }; +function FigureCard({ + figure, + executionGroup, + onClick, +}: FigureWithGroup & { onClick: () => void }) { + return ( + + +
+
+
+ + Group: {executionGroup.key} + + + Filename:{" "} + {figure.filename} + +
+ +
+ + + ); +} + +function PaginationControls({ + currentPage, + totalPages, + onPageChange, +}: { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +}) { + return ( +
+ + + Page {currentPage + 1} of {totalPages} + + +
+ ); +} + export function FigureGallery({ providerSlug, diagnosticSlug, + pageSize, }: DiagnosticFigureGalleryProps) { const [filter, setFilter] = useState(""); const [selectorFilters, setSelectorFilters] = useState< @@ -74,6 +157,7 @@ export function FigureGallery({ null, ); const [isModalOpen, setIsModalOpen] = useState(false); + const [currentPage, setCurrentPage] = useState(0); const getColumns = useCallback(() => { const width = window.innerWidth; @@ -104,13 +188,14 @@ export function FigureGallery({ const allFigures = useMemo( () => - groups.flatMap((group) => - (group.executions ?? []).flatMap((execution) => - (execution.outputs ?? []) - .filter((output) => output.output_type === "plot") - .map((figure) => ({ figure, executionGroup: group })), - ), - ), + groups.flatMap((group) => { + // Only use the latest execution to avoid showing duplicate/outdated figures + const latestExecution = group.latest_execution; + if (!latestExecution) return []; + return (latestExecution.outputs ?? []) + .filter((output) => output.output_type === "plot") + .map((figure) => ({ figure, executionGroup: group })); + }), [groups], ); @@ -139,23 +224,42 @@ export function FigureGallery({ }); }, [allFigures, selectorFilters, filter]); + // Reset page when filters change + // biome-ignore lint/correctness/useExhaustiveDependencies: reset page on filter change + useEffect(() => { + setCurrentPage(0); + }, [filter, selectorFilters]); + + const usePagination = pageSize !== undefined && pageSize > 0; + const totalPages = usePagination + ? Math.ceil(filteredFigures.length / pageSize) + : 1; + + const visibleFigures = usePagination + ? filteredFigures.slice( + currentPage * pageSize, + (currentPage + 1) * pageSize, + ) + : filteredFigures; + const listRef = useRef(null); const [scrollMargin, setScrollMargin] = useState(0); // biome-ignore lint/correctness/useExhaustiveDependencies: filteredFigures.length triggers re-measurement when filter changes affect layout position - useEffect(() => { - if (listRef.current) { + useLayoutEffect(() => { + if (!usePagination && listRef.current) { const rect = listRef.current.getBoundingClientRect(); setScrollMargin(rect.top + window.scrollY); } - }, [filteredFigures.length]); + }, [filteredFigures.length, usePagination]); const totalRows = Math.ceil(filteredFigures.length / columns); const rowVirtualizer = useWindowVirtualizer({ - count: totalRows, + count: usePagination ? 0 : totalRows, estimateSize: () => 400, overscan: 9, scrollMargin, + enabled: !usePagination, }); const goToPrevious = useCallback(() => { @@ -174,6 +278,11 @@ export function FigureGallery({ return ; } + const openFigure = (index: number) => { + setSelectedFigureIndex(index); + setIsModalOpen(true); + }; + const items = rowVirtualizer.getVirtualItems(); return (
@@ -194,71 +303,75 @@ export function FigureGallery({ /> {filteredFigures.length > 0 ? ( -
+ usePagination ? ( + <> +
+ Showing {currentPage * pageSize + 1}– + {Math.min((currentPage + 1) * pageSize, filteredFigures.length)}{" "} + of {filteredFigures.length} figures +
+
+ {visibleFigures.map(({ figure, executionGroup }, index) => { + const globalIndex = currentPage * pageSize + index; + return ( + openFigure(globalIndex)} + /> + ); + })} +
+ {totalPages > 1 && ( + + )} + + ) : (
- {items.map((virtualRow) => ( -
-
- {Array.from({ length: columns }, (_, colIndex) => { - const globalIndex = virtualRow.index * columns + colIndex; - if (globalIndex >= filteredFigures.length) return null; - const { figure, executionGroup } = - filteredFigures[globalIndex]; - return ( - { - setSelectedFigureIndex(globalIndex); - setIsModalOpen(true); - }} - > - -
- -
-
- - Group:{" "} - {executionGroup.key} - - - - Filename: - {" "} - {figure.filename} - -
- - -
- - - ); - })} +
+ {items.map((virtualRow) => ( +
+
+ {Array.from({ length: columns }, (_, colIndex) => { + const globalIndex = virtualRow.index * columns + colIndex; + if (globalIndex >= filteredFigures.length) return null; + const { figure, executionGroup } = + filteredFigures[globalIndex]; + return ( + openFigure(globalIndex)} + /> + ); + })} +
-
- ))} + ))} +
-
+ ) ) : (
No figures found matching your filters. diff --git a/frontend/src/components/explorer/content/figureGalleryContent.tsx b/frontend/src/components/explorer/content/figureGalleryContent.tsx index 05303f3..649c14b 100644 --- a/frontend/src/components/explorer/content/figureGalleryContent.tsx +++ b/frontend/src/components/explorer/content/figureGalleryContent.tsx @@ -12,6 +12,7 @@ export function FigureGalleryContent({ ); } diff --git a/frontend/src/components/explorer/thematicContent.tsx b/frontend/src/components/explorer/thematicContent.tsx index 903f2e4..5833000 100644 --- a/frontend/src/components/explorer/thematicContent.tsx +++ b/frontend/src/components/explorer/thematicContent.tsx @@ -94,12 +94,15 @@ function toExplorerCardContent( function collectionToExplorerCards( collection: AftCollectionDetail, ): ExplorerCard[] { - return collection.explorer_cards.map((card: AftCollectionCard) => ({ - title: card.title, - description: card.description ?? undefined, - placeholder: card.placeholder ?? undefined, - content: card.content.map(toExplorerCardContent), - })); + return collection.explorer_cards + .filter((card: AftCollectionCard) => !card.placeholder) + .map((card: AftCollectionCard) => ({ + title: card.title, + description: card.description ?? undefined, + content: card.content + .filter((c: AftCollectionCardContent) => !c.placeholder) + .map(toExplorerCardContent), + })); } function buildCollectionGroups(theme: ThemeDetail) { diff --git a/frontend/src/lib/latex.test.ts b/frontend/src/lib/latex.test.ts new file mode 100644 index 0000000..648d098 --- /dev/null +++ b/frontend/src/lib/latex.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { containsLatex, renderLatexToHtml } from "./latex"; + +describe("containsLatex", () => { + it("returns true for strings with $...$ delimiters", () => { + expect(containsLatex("Temperature at 2.0 $^\\circ$ C")).toBe(true); + }); + + it("returns true for multiple LaTeX expressions", () => { + expect(containsLatex("$x^2$ and $y^2$")).toBe(true); + }); + + it("returns false for strings without LaTeX", () => { + expect(containsLatex("Temperature at 2.0 degrees C")).toBe(false); + }); + + it("returns false for empty dollar signs", () => { + expect(containsLatex("price is $$ today")).toBe(false); + }); + + it("returns false for single dollar signs without pairs", () => { + expect(containsLatex("costs $5")).toBe(false); + }); +}); + +describe("renderLatexToHtml", () => { + it("renders degree symbol from LaTeX notation", () => { + const result = renderLatexToHtml( + "Multimodel standard deviation of Temperature at 2.0 $^\\circ$ C", + ); + // KaTeX should render the LaTeX part as HTML containing the degree symbol + expect(result).toContain("katex"); + expect(result).not.toContain("$^\\circ$"); + }); + + it("passes through strings without LaTeX unchanged (HTML-escaped)", () => { + const result = renderLatexToHtml("Simple text without LaTeX"); + expect(result).toBe("Simple text without LaTeX"); + }); + + it("handles multiple LaTeX expressions in one string", () => { + const result = renderLatexToHtml("$x^2$ plus $y^2$"); + expect(result).toContain("katex"); + expect(result).not.toContain("$x^2$"); + expect(result).not.toContain("$y^2$"); + // The plain text " plus " should still be present + expect(result).toContain(" plus "); + }); + + it("escapes HTML in non-LaTeX segments", () => { + const result = renderLatexToHtml("a < b & c > d $x^2$"); + expect(result).toContain("<"); + expect(result).toContain("&"); + expect(result).toContain(">"); + }); + + it("handles malformed LaTeX without throwing", () => { + // KaTeX with throwOnError: false should not throw + const result = renderLatexToHtml("$\\invalidcommand$"); + expect(typeof result).toBe("string"); + }); + + it("renders superscripts", () => { + const result = renderLatexToHtml("CO$_2$ emissions"); + expect(result).toContain("katex"); + expect(result).not.toContain("$_2$"); + }); +}); diff --git a/frontend/src/lib/latex.ts b/frontend/src/lib/latex.ts new file mode 100644 index 0000000..8a82ecb --- /dev/null +++ b/frontend/src/lib/latex.ts @@ -0,0 +1,48 @@ +import katex from "katex"; +import "katex/dist/katex.min.css"; + +const LATEX_DELIMITER = /(\$[^$]+\$)/g; +const LATEX_TEST = /\$[^$]+\$/; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +/** + * Render inline LaTeX math expressions (`$...$`) within a plain text string + * to HTML. Non-math text is left as-is (HTML-escaped). + * + * KaTeX is loaded in a separate chunk (see vite.config.ts) so it doesn't + * bloat the main bundle. + */ +export function renderLatexToHtml(text: string): string { + const parts = text.split(LATEX_DELIMITER); + + return parts + .map((part) => { + if (part.startsWith("$") && part.endsWith("$")) { + const latex = part.slice(1, -1); + try { + return katex.renderToString(latex, { + throwOnError: false, + output: "html", + }); + } catch { + // Guard against unexpected KaTeX internal errors + return escapeHtml(part); + } + } + return escapeHtml(part); + }) + .join(""); +} + +/** + * Check whether a string contains any LaTeX math delimiters. + */ +export function containsLatex(text: string): boolean { + return LATEX_TEST.test(text); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 54494ec..804b2ff 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,6 +10,13 @@ import { defineConfig } from "vite"; export default defineConfig(({ mode }) => ({ build: { sourcemap: true, // Source map generation must be turned on + rollupOptions: { + output: { + manualChunks: { + katex: ["katex"], + }, + }, + }, }, server: { proxy: {