From 6869d44bf5de4146961f019f1f852353712255a6 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Mon, 30 Mar 2026 23:41:18 +0900 Subject: [PATCH 1/5] fix(frontend): address explorer feedback issues - Fix duplicate figures by using only latest execution per group - Add KaTeX for rendering LaTeX math in figure captions (e.g. degree symbols) - Fix season ordering to include Annual alongside DJF/MAM/JJA/SON - Show rotated x-axis labels instead of hiding them for charts with many categories - Filter out placeholder cards from explorer view - Fix figure gallery whitespace by using useLayoutEffect for scroll margin --- frontend/package-lock.json | 26 ++++++++++++ frontend/package.json | 1 + .../components/diagnostics/ensembleChart.tsx | 13 +++--- .../src/components/diagnostics/figure.tsx | 15 ++++++- .../components/diagnostics/figureGallery.tsx | 26 ++++++++---- .../components/explorer/thematicContent.tsx | 16 ++++--- frontend/src/lib/latex.ts | 42 +++++++++++++++++++ frontend/vite.config.ts | 7 ++++ 8 files changed, 125 insertions(+), 21 deletions(-) create mode 100644 frontend/src/lib/latex.ts 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.tsx b/frontend/src/components/diagnostics/ensembleChart.tsx index d0d6f4f..f386e63 100644 --- a/frontend/src/components/diagnostics/ensembleChart.tsx +++ b/frontend/src/components/diagnostics/ensembleChart.tsx @@ -24,8 +24,8 @@ 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"], + // Meteorological seasons (Annual first, then chronological) + season: ["Annual", "annual", "ANN", "DJF", "MAM", "JJA", "SON"], }; /** @@ -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,10 @@ 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={0} /> ) { + const hasLatex = long_name ? containsLatex(long_name) : false; + return (
{long_name} - {long_name} + {hasLatex ? ( + + ) : ( + + {long_name} + + )}
); } diff --git a/frontend/src/components/diagnostics/figureGallery.tsx b/frontend/src/components/diagnostics/figureGallery.tsx index 69965ab..d87baa4 100644 --- a/frontend/src/components/diagnostics/figureGallery.tsx +++ b/frontend/src/components/diagnostics/figureGallery.tsx @@ -2,7 +2,14 @@ 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 { + 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"; @@ -104,13 +111,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], ); @@ -143,7 +151,7 @@ export function FigureGallery({ const [scrollMargin, setScrollMargin] = useState(0); // biome-ignore lint/correctness/useExhaustiveDependencies: filteredFigures.length triggers re-measurement when filter changes affect layout position - useEffect(() => { + useLayoutEffect(() => { if (listRef.current) { const rect = listRef.current.getBoundingClientRect(); setScrollMargin(rect.top + window.scrollY); diff --git a/frontend/src/components/explorer/thematicContent.tsx b/frontend/src/components/explorer/thematicContent.tsx index 903f2e4..4b67d19 100644 --- a/frontend/src/components/explorer/thematicContent.tsx +++ b/frontend/src/components/explorer/thematicContent.tsx @@ -94,12 +94,16 @@ 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, + placeholder: card.placeholder ?? undefined, + content: card.content + .filter((c: AftCollectionCardContent) => !c.placeholder) + .map(toExplorerCardContent), + })); } function buildCollectionGroups(theme: ThemeDetail) { diff --git a/frontend/src/lib/latex.ts b/frontend/src/lib/latex.ts new file mode 100644 index 0000000..0d0aa00 --- /dev/null +++ b/frontend/src/lib/latex.ts @@ -0,0 +1,42 @@ +import katex from "katex"; +import "katex/dist/katex.min.css"; + +/** + * 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 { + // Split on $...$ math delimiters, preserving the groups + const parts = text.split(/(\$[^$]+\$)/g); + + 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 { + return part; + } + } + // Escape HTML in plain text segments + return part + .replace(/&/g, "&") + .replace(//g, ">"); + }) + .join(""); +} + +/** + * Check whether a string contains any LaTeX math delimiters. + */ +export function containsLatex(text: string): boolean { + return /\$[^$]+\$/.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: { From f40e9a4aaf84f681b100c0477aded31042d6c31b Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Mon, 30 Mar 2026 23:44:36 +0900 Subject: [PATCH 2/5] test: add tests for latex rendering and season sort ordering - Tests for renderLatexToHtml and containsLatex (LaTeX to HTML conversion) - Tests for sortCategories with season auto-detection, Annual variants, explicit ordering, and edge cases - Export sortCategories and KNOWN_CATEGORY_ORDERS for testability --- .../diagnostics/ensembleChart.test.ts | 77 +++++++++++++++++++ .../components/diagnostics/ensembleChart.tsx | 4 +- frontend/src/lib/latex.test.ts | 68 ++++++++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/diagnostics/ensembleChart.test.ts create mode 100644 frontend/src/lib/latex.test.ts 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 f386e63..1e00389 100644 --- a/frontend/src/components/diagnostics/ensembleChart.tsx +++ b/frontend/src/components/diagnostics/ensembleChart.tsx @@ -23,7 +23,7 @@ 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 = { +export const KNOWN_CATEGORY_ORDERS: Record = { // Meteorological seasons (Annual first, then chronological) season: ["Annual", "annual", "ANN", "DJF", "MAM", "JJA", "SON"], }; @@ -32,7 +32,7 @@ const KNOWN_CATEGORY_ORDERS: Record = { * 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[] { 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$"); + }); +}); From 3bfdacac7b4576a554238d61aa5d58e751bfbe1f Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Mon, 30 Mar 2026 23:51:23 +0900 Subject: [PATCH 3/5] fix(frontend): add optional pagination to figure gallery Add pageSize prop to FigureGallery that switches from infinite scroll (window virtualizer) to paginated mode with previous/next controls and a page counter. Explorer galleries use pageSize=12 by default so users can see there are more figures without scrolling through hundreds. Diagnostics page still uses infinite scroll. Also extracts FigureCard as a reusable component. --- .../components/diagnostics/figureGallery.tsx | 232 +++++++++++++----- .../explorer/content/figureGalleryContent.tsx | 1 + 2 files changed, 170 insertions(+), 63 deletions(-) diff --git a/frontend/src/components/diagnostics/figureGallery.tsx b/frontend/src/components/diagnostics/figureGallery.tsx index d87baa4..1d9130a 100644 --- a/frontend/src/components/diagnostics/figureGallery.tsx +++ b/frontend/src/components/diagnostics/figureGallery.tsx @@ -1,7 +1,13 @@ 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 { + ChevronLeft, + ChevronRight, + Download, + ExternalLink, + MoreHorizontal, +} from "lucide-react"; import { useCallback, useEffect, @@ -33,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 { @@ -69,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< @@ -81,6 +157,7 @@ export function FigureGallery({ null, ); const [isModalOpen, setIsModalOpen] = useState(false); + const [currentPage, setCurrentPage] = useState(0); const getColumns = useCallback(() => { const width = window.innerWidth; @@ -147,23 +224,43 @@ 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; + + // Figures for the current page (paginated) or all figures (virtualized) + 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 useLayoutEffect(() => { - if (listRef.current) { + 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(() => { @@ -182,6 +279,11 @@ export function FigureGallery({ return ; } + const openFigure = (index: number) => { + setSelectedFigureIndex(index); + setIsModalOpen(true); + }; + const items = rowVirtualizer.getVirtualItems(); return (
@@ -202,71 +304,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({ ); } From 63552c7fdbe0a2ae461c77501a172cf83d018229 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Tue, 31 Mar 2026 00:12:11 +0900 Subject: [PATCH 4/5] refactor: address code review findings - Memoize KaTeX rendering in Figure component with useMemo - Hoist LaTeX regex patterns to module scope to avoid re-creation - Remove dead try/catch (KaTeX throwOnError:false handles errors) - Remove redundant placeholder field from filtered cards - Cap XAxis interval at ~20 labels for very large datasets - Remove unnecessary comment --- .../components/diagnostics/ensembleChart.tsx | 6 +++- .../src/components/diagnostics/figure.tsx | 31 ++++++++--------- .../components/diagnostics/figureGallery.tsx | 1 - .../components/explorer/thematicContent.tsx | 1 - frontend/src/lib/latex.ts | 33 ++++++++++--------- 5 files changed, 38 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/diagnostics/ensembleChart.tsx b/frontend/src/components/diagnostics/ensembleChart.tsx index 1e00389..37f7d6b 100644 --- a/frontend/src/components/diagnostics/ensembleChart.tsx +++ b/frontend/src/components/diagnostics/ensembleChart.tsx @@ -348,7 +348,11 @@ export const EnsembleChart = ({ angle={shouldRotateXAxis ? -45 : 0} textAnchor={shouldRotateXAxis ? "end" : "middle"} height={shouldRotateXAxis ? 100 : 30} - interval={0} + 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} - {hasLatex ? ( + {captionHtml ? ( ) : ( diff --git a/frontend/src/components/diagnostics/figureGallery.tsx b/frontend/src/components/diagnostics/figureGallery.tsx index 1d9130a..0c44d82 100644 --- a/frontend/src/components/diagnostics/figureGallery.tsx +++ b/frontend/src/components/diagnostics/figureGallery.tsx @@ -235,7 +235,6 @@ export function FigureGallery({ ? Math.ceil(filteredFigures.length / pageSize) : 1; - // Figures for the current page (paginated) or all figures (virtualized) const visibleFigures = usePagination ? filteredFigures.slice( currentPage * pageSize, diff --git a/frontend/src/components/explorer/thematicContent.tsx b/frontend/src/components/explorer/thematicContent.tsx index 4b67d19..5833000 100644 --- a/frontend/src/components/explorer/thematicContent.tsx +++ b/frontend/src/components/explorer/thematicContent.tsx @@ -99,7 +99,6 @@ function collectionToExplorerCards( .map((card: AftCollectionCard) => ({ title: card.title, description: card.description ?? undefined, - placeholder: card.placeholder ?? undefined, content: card.content .filter((c: AftCollectionCardContent) => !c.placeholder) .map(toExplorerCardContent), diff --git a/frontend/src/lib/latex.ts b/frontend/src/lib/latex.ts index 0d0aa00..cedb451 100644 --- a/frontend/src/lib/latex.ts +++ b/frontend/src/lib/latex.ts @@ -1,6 +1,16 @@ 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). @@ -9,27 +19,18 @@ import "katex/dist/katex.min.css"; * bloat the main bundle. */ export function renderLatexToHtml(text: string): string { - // Split on $...$ math delimiters, preserving the groups - const parts = text.split(/(\$[^$]+\$)/g); + 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 { - return part; - } + return katex.renderToString(latex, { + throwOnError: false, + output: "html", + }); } - // Escape HTML in plain text segments - return part - .replace(/&/g, "&") - .replace(//g, ">"); + return escapeHtml(part); }) .join(""); } @@ -38,5 +39,5 @@ export function renderLatexToHtml(text: string): string { * Check whether a string contains any LaTeX math delimiters. */ export function containsLatex(text: string): boolean { - return /\$[^$]+\$/.test(text); + return LATEX_TEST.test(text); } From 7fe7392a77412830056c02828708ffd72a94066c Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Tue, 31 Mar 2026 00:21:06 +0900 Subject: [PATCH 5/5] fix: address PR review comments - Re-add try/catch in renderLatexToHtml with escaped fallback to prevent XSS if KaTeX throws an unexpected internal error - Stop event propagation on FigureDropDown trigger button to prevent card onClick from firing when interacting with the dropdown menu --- .../src/components/diagnostics/figureGallery.tsx | 2 +- frontend/src/lib/latex.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/diagnostics/figureGallery.tsx b/frontend/src/components/diagnostics/figureGallery.tsx index 0c44d82..2bfc298 100644 --- a/frontend/src/components/diagnostics/figureGallery.tsx +++ b/frontend/src/components/diagnostics/figureGallery.tsx @@ -52,7 +52,7 @@ const FigureDropDown = ({ figure, executionGroup }: FigureWithGroup) => { return ( - diff --git a/frontend/src/lib/latex.ts b/frontend/src/lib/latex.ts index cedb451..8a82ecb 100644 --- a/frontend/src/lib/latex.ts +++ b/frontend/src/lib/latex.ts @@ -25,10 +25,15 @@ export function renderLatexToHtml(text: string): string { .map((part) => { if (part.startsWith("$") && part.endsWith("$")) { const latex = part.slice(1, -1); - return katex.renderToString(latex, { - throwOnError: false, - output: "html", - }); + try { + return katex.renderToString(latex, { + throwOnError: false, + output: "html", + }); + } catch { + // Guard against unexpected KaTeX internal errors + return escapeHtml(part); + } } return escapeHtml(part); })