Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/src/components/AnalysisChart.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
// © 2026 Joshua Benjamin Jewell. All rights reserved.
// Licensed under the GNU Affero General Public License version 3 (AGPLv3).
import Plotly from 'plotly.js-dist-min'
import { stripHtmlTags } from '../utils/text'
import { AddToReport } from './AddToReport'
import { PlotlyChart } from './PlotlyChart'

const figureTitle = (fig: unknown): string | null => {
const t = (fig as { layout?: { title?: unknown } })?.layout?.title
const text = typeof t === 'string' ? t : (t as { text?: string } | undefined)?.text
return text ? text.replace(/<[^>]+>/g, '') : null
return text ? stripHtmlTags(text) : null
}

// The chart as standalone SVG, at a fixed print width.
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/figure/charts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the GNU Affero General Public License version 3 (AGPLv3).
import { api } from '../api/client'
import { extractAlphaPanel } from '../components/alphaMetrics'
import { stripHtmlTags } from '../utils/text'
import { textWidth } from './legend'
import { PX_PER_PT, tickPt, titlePt, type AlphaMetricKey, type ChartSpec, type FigureDoc, type FigurePane } from './types'

Expand Down Expand Up @@ -102,71 +103,71 @@
}

/** The chart sized, typeset and titled for its pane. */
export function paneFigure(raw: unknown, pane: FigurePane, doc: FigureDoc, opts: {
showLegend: boolean
widthPx: number
heightPx: number
}): PlotSpec {
const fig = JSON.parse(JSON.stringify(raw)) as PlotSpec
const base = doc.font.sizePt * PX_PER_PT
const tick = tickPt(doc) * PX_PER_PT
const style = pane.style ?? {}
const family = doc.font.family === 'Arial' ? 'Arial, Helvetica, sans-serif' : '"Times New Roman", Times, serif'
const L = fig.layout ?? {}

const title = pane.title ?? titleText(L.title)
const hasTitle = title.trim().length > 0
// A title wider than its pane shrinks to fit, clear of the group letter.
const titleRoom = opts.widthPx - 2 * doc.letters.sizePt * PX_PER_PT
const titleSize = Math.min(titlePt(doc) * PX_PER_PT,
titlePt(doc) * PX_PER_PT * titleRoom / Math.max(1, textWidth(title.replace(/<[^>]+>/g, ''), titlePt(doc) * PX_PER_PT, family)))
titlePt(doc) * PX_PER_PT * titleRoom / Math.max(1, textWidth(stripHtmlTags(title), titlePt(doc) * PX_PER_PT, family)))

for (const k of axisKeys(L, 'x').concat(axisKeys(L, 'y'))) {
const ax = { ...(L[k] as Record<string, unknown>) }
ax.automargin = true
ax.tickfont = { ...(ax.tickfont as object | undefined), size: tick }
if (k.startsWith('x') && style.tickAngle != null) ax.tickangle = style.tickAngle
if (k.startsWith('x') && style.allTicks) { ax.tickmode = 'linear'; ax.dtick = 1 }
if (ax.title) ax.title = { ...(typeof ax.title === 'string' ? { text: ax.title } : ax.title as object), font: { size: base } }
L[k] = ax
}
if (pane.xTitle != null) {
const xs = axisKeys(L, 'x')
const titled = xs.filter(k => titleText((L[k] as Record<string, unknown>).title))
for (const k of titled.length ? titled : xs.slice(0, 1))
L[k] = { ...(L[k] as object), title: { text: pane.xTitle, font: { size: base } } }
}
// Grid charts head each row and column with "run: X"; a figure shows just X.
const facetLabel = /(^|>)(run|group|subgroup): /
const isGrid = L.grid != null ||
(Array.isArray(L.annotations) && (L.annotations as { text?: string }[]).some(a => facetLabel.test(a.text ?? '')))
if (Array.isArray(L.annotations)) {
L.annotations = (L.annotations as Record<string, unknown>[]).map(a => ({
...a,
...(typeof a.text === 'string' && facetLabel.test(a.text)
? { text: a.text.replace(facetLabel, '$1').replace(/_/g, ' ') } : {}),
font: { ...(a.font as object | undefined), size: base * 0.9 },
}))
}

if (L.grid && style.gridGap != null) L.grid = { ...(L.grid as object), xgap: style.gridGap }
if (L.grid && style.gridRowGap != null) L.grid = { ...(L.grid as object), ygap: style.gridRowGap }

fig.data = (fig.data ?? []).map(raw => {
let t = recolour(raw.type === 'box' && raw.width == null ? { ...raw, width: 0.9 } : raw, doc.colours ?? {})
const lineOnly = t.type === 'scatter' && t.mode === 'lines'
if (lineOnly && style.lineOpacity != null) {
const c = parseColour(String((t.line as Record<string, unknown> | undefined)?.color ?? '')) ?? [70, 70, 70, 1]
t = { ...t, line: { ...(t.line as object | undefined), color: rgba(c, style.lineOpacity) } }
}
const hasPoints = t.type === 'box' || (t.type === 'scatter' && String(t.mode ?? 'markers').includes('markers'))
if (hasPoints && style.pointBorder != null) {
const m = (t.marker ?? {}) as Record<string, unknown>
t = { ...t, marker: { ...m, line: { ...(m.line as object | undefined), width: style.pointBorder } } }
}
if (hasPoints && style.pointOpacity != null) t = { ...t, marker: { ...(t.marker as object | undefined), opacity: style.pointOpacity } }
return t
})

Check notice on line 170 in frontend/src/figure/charts.ts

View check run for this annotation

codefactor.io / CodeFactor

frontend/src/figure/charts.ts#L156-L170

Complex Method
fig.layout = {
...L,
width: opts.widthPx, height: opts.heightPx, autosize: false,
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/utils/text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// © 2026 Joshua Benjamin Jewell. All rights reserved.
// Licensed under the GNU Affero General Public License version 3 (AGPLv3).
import { describe, expect, test } from 'bun:test'
import { splitLines, stripHtmlTags } from './text'

describe('stripHtmlTags', () => {
test('removes the markup Plotly allows in a title', () => {
expect(stripHtmlTags('CO<sub>2</sub> by <b>run</b>')).toBe('CO2 by run')
})

test('a tag hiding inside another does not survive', () => {
expect(stripHtmlTags('<scr<script>ipt>alert(1)</script>')).not.toMatch(/<[^>]+>/)
})

test('text without markup, including a bare >, is unchanged', () => {
expect(stripHtmlTags('p > 0.05 for all')).toBe('p > 0.05 for all')
})
})

describe('splitLines', () => {
test('one trimmed value per line, blanks dropped', () => {
expect(splitLines(' a\n\n b \nc')).toEqual(['a', 'b', 'c'])
})
})
17 changes: 17 additions & 0 deletions frontend/src/utils/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,20 @@
export function splitLines(text: string): string[] {
return text.split('\n').map(v => v.trim()).filter(v => v.length > 0)
}

/**
* Strip the pseudo-HTML markup Plotly accepts in titles and axis labels (<b>,
* <br>, <sub> and friends), leaving the bare text for measuring or for a
* filename. A single pass is not enough: replacing "<scr<script>ipt>" once
* removes the inner tag and leaves the halves joined into another, so the
* strip repeats until the text stops changing.
*/
export function stripHtmlTags(text: string): string {
let stripped = text
let previous = ''
do {
previous = stripped
stripped = stripped.replace(/<[^>]+>/g, '')

Check warning on line 25 in frontend/src/utils/text.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=hyperpolymath_MetaManifold-WebUI&issues=AaE8xU-994ktle_vglEH&open=AaE8xU-994ktle_vglEH&pullRequest=119
} while (stripped !== previous)
return stripped
}
Loading