Skip to content

Commit 77aa146

Browse files
committed
chore: centralise some shared chart utils
1 parent eb5edbe commit 77aa146

2 files changed

Lines changed: 350 additions & 1 deletion

File tree

app/utils/charts.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type {
22
AltCopyArgs,
3+
VueUiHorizontalBarConfig,
4+
VueUiHorizontalBarDatapoint,
35
VueUiXyConfig,
46
VueUiXyDatasetBarItem,
57
VueUiXyDatasetLineItem,
@@ -446,6 +448,13 @@ export type VersionsBarConfig = Omit<
446448
'formattedDates' | 'hasEstimation' | 'formattedDatasetValues' | 'granularity'
447449
> & { datapointLabels: string[]; dateRangeLabel: string; semverGroupingMode: string }
448450

451+
export type FacetBarChartConfig = VueUiHorizontalBarConfig & {
452+
facet: string // translated
453+
description: string // translated
454+
copy: (text: string) => Promise<void>
455+
$t: TrendTranslateFunction
456+
}
457+
449458
// Used for TrendsChart.vue
450459
export function createAltTextForTrendLineChart({
451460
dataset,
@@ -595,3 +604,126 @@ export async function copyAltTextForVersionsBarChart({
595604
const altText = createAltTextForVersionsBarChart({ dataset, config })
596605
await config.copy(altText)
597606
}
607+
608+
// Used for FacetBarChart.vue
609+
export function createAltTextForCompareFacetBarChart({
610+
dataset,
611+
config,
612+
}: AltCopyArgs<VueUiHorizontalBarDatapoint[], FacetBarChartConfig>) {
613+
if (!dataset) return ''
614+
const { facet, description, $t } = config
615+
616+
console.log(config)
617+
618+
const packages = dataset.map(d => d.name).join(', ')
619+
const facet_analysis = dataset
620+
.map(d =>
621+
$t('package.trends.copy_alt.facet_bar_analysis', {
622+
package_name: d.name,
623+
value: d.formattedValue,
624+
}),
625+
)
626+
.join(' ')
627+
628+
const altText = `${config.$t('package.trends.copy_alt.facet_bar_general_description', {
629+
packages,
630+
facet,
631+
description,
632+
facet_analysis,
633+
})}`
634+
635+
return altText
636+
}
637+
638+
export async function copyAltTextForCompareFacetBarChart({
639+
dataset,
640+
config,
641+
}: AltCopyArgs<VueUiHorizontalBarDatapoint[], FacetBarChartConfig>) {
642+
const altText = createAltTextForCompareFacetBarChart({ dataset, config })
643+
await config.copy(altText)
644+
}
645+
646+
// Used in chart context menu callbacks
647+
export function loadFile(link: string, filename: string) {
648+
const a = document.createElement('a')
649+
a.href = link
650+
a.download = filename
651+
a.click()
652+
a.remove()
653+
}
654+
655+
export function sanitise(value: string) {
656+
return value
657+
.replace(/^@/, '')
658+
.replace(/[\\/:"*?<>|]/g, '-')
659+
.replace(/\//g, '-')
660+
}
661+
662+
// Create multi-line labels for long names
663+
export function insertLineBreaks(text: string, maxCharactersPerLine = 24) {
664+
if (typeof text !== 'string') {
665+
return ''
666+
}
667+
668+
if (!Number.isInteger(maxCharactersPerLine) || maxCharactersPerLine <= 0) {
669+
return text
670+
}
671+
672+
const tokens = text.match(/\S+|\s+/g) || []
673+
const lines: string[] = []
674+
let currentLine = ''
675+
676+
const pushLine = () => {
677+
const trimmedLine = currentLine.trim()
678+
679+
if (trimmedLine.length) {
680+
lines.push(trimmedLine)
681+
}
682+
683+
currentLine = ''
684+
}
685+
686+
for (const token of tokens) {
687+
if (/^\s+$/.test(token)) {
688+
if (currentLine.length && !currentLine.endsWith(' ')) {
689+
currentLine += ' '
690+
}
691+
continue
692+
}
693+
694+
if (token.length > maxCharactersPerLine) {
695+
pushLine()
696+
697+
for (let index = 0; index < token.length; index += maxCharactersPerLine) {
698+
lines.push(token.slice(index, index + maxCharactersPerLine))
699+
}
700+
continue
701+
}
702+
703+
const candidate = currentLine.length ? `${currentLine}${token}` : token
704+
705+
if (candidate.length <= maxCharactersPerLine) {
706+
currentLine = candidate
707+
} else {
708+
pushLine()
709+
currentLine = token
710+
}
711+
}
712+
713+
pushLine()
714+
715+
return lines.join('\n')
716+
}
717+
718+
export function applyEllipsis(text: string, maxLength = 45) {
719+
if (typeof text !== 'string') {
720+
return ''
721+
}
722+
if (!Number.isInteger(maxLength) || maxLength <= 0) {
723+
return text
724+
}
725+
if (text.length <= maxLength) {
726+
return text
727+
}
728+
return text.slice(0, maxLength) + '...'
729+
}

test/unit/app/utils/charts.spec.ts

Lines changed: 218 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, vi } from 'vitest'
1+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
22
import {
33
sum,
44
chunkIntoWeeks,
@@ -12,6 +12,10 @@ import {
1212
copyAltTextForTrendLineChart,
1313
createAltTextForVersionsBarChart,
1414
copyAltTextForVersionsBarChart,
15+
loadFile,
16+
sanitise,
17+
insertLineBreaks,
18+
applyEllipsis,
1519
type TrendLineConfig,
1620
type TrendLineDataset,
1721
type VersionsBarConfig,
@@ -1236,3 +1240,216 @@ describe('copyAltTextForVersionsBarChart', () => {
12361240
expect(copyMock).toHaveBeenCalledWith(expected)
12371241
})
12381242
})
1243+
1244+
describe('loadFile', () => {
1245+
let createElementMock: ReturnType<typeof vi.fn>
1246+
let clickMock: ReturnType<typeof vi.fn>
1247+
let removeMock: ReturnType<typeof vi.fn>
1248+
let originalDocument: typeof globalThis.document | undefined
1249+
1250+
beforeEach(() => {
1251+
clickMock = vi.fn()
1252+
removeMock = vi.fn()
1253+
1254+
createElementMock = vi.fn().mockReturnValue({
1255+
href: '',
1256+
download: '',
1257+
click: clickMock,
1258+
remove: removeMock,
1259+
})
1260+
1261+
originalDocument = globalThis.document
1262+
1263+
Object.defineProperty(globalThis, 'document', {
1264+
value: {
1265+
createElement: createElementMock,
1266+
},
1267+
configurable: true,
1268+
writable: true,
1269+
})
1270+
})
1271+
1272+
afterEach(() => {
1273+
vi.restoreAllMocks()
1274+
1275+
Object.defineProperty(globalThis, 'document', {
1276+
value: originalDocument,
1277+
configurable: true,
1278+
writable: true,
1279+
})
1280+
})
1281+
1282+
it('creates an anchor element and triggers a download', () => {
1283+
const link = 'https://npmx.dev/file.png'
1284+
const filename = 'file.png'
1285+
loadFile(link, filename)
1286+
expect(createElementMock).toHaveBeenCalledWith('a')
1287+
const anchor = createElementMock.mock.results[0]?.value as HTMLAnchorElement
1288+
expect(anchor.href).toBe(link)
1289+
expect(anchor.download).toBe(filename)
1290+
expect(clickMock).toHaveBeenCalledTimes(1)
1291+
expect(removeMock).toHaveBeenCalledTimes(1)
1292+
})
1293+
})
1294+
1295+
describe('sanitise', () => {
1296+
it('returns the same string when no sanitisation is needed', () => {
1297+
expect(sanitise('nuxt-package')).toBe('nuxt-package')
1298+
})
1299+
1300+
it('removes a leading @ character', () => {
1301+
expect(sanitise('@nuxt/ui')).toBe('nuxt-ui')
1302+
})
1303+
1304+
it('removes only the first leading @ character', () => {
1305+
expect(sanitise('@@scope/package')).toBe('@scope-package')
1306+
})
1307+
1308+
it('replaces forward slashes with dashes', () => {
1309+
expect(sanitise('scope/package/name')).toBe('scope-package-name')
1310+
})
1311+
1312+
it('replaces backslashes with dashes', () => {
1313+
expect(sanitise('scope\\package\\name')).toBe('scope-package-name')
1314+
})
1315+
1316+
it('replaces colon characters with dashes', () => {
1317+
expect(sanitise('name:with:colons')).toBe('name-with-colons')
1318+
})
1319+
1320+
it('replaces invalid filename characters with dashes', () => {
1321+
expect(sanitise('na<me>:"with"*?pipes|')).toBe('na-me---with---pipes-')
1322+
})
1323+
1324+
it('handles scoped package names correctly', () => {
1325+
expect(sanitise('@scope/package')).toBe('scope-package')
1326+
})
1327+
1328+
it('replaces mixed invalid characters in a single string', () => {
1329+
expect(sanitise('@scope/package:name*test?value<foo>|bar')).toBe(
1330+
'scope-package-name-test-value-foo--bar',
1331+
)
1332+
})
1333+
1334+
it('returns an empty string when given an empty string', () => {
1335+
expect(sanitise('')).toBe('')
1336+
})
1337+
})
1338+
1339+
describe('insertLineBreaks', () => {
1340+
it('returns an empty string when text is not a string', () => {
1341+
expect(insertLineBreaks(null as unknown as string)).toBe('')
1342+
expect(insertLineBreaks(undefined as unknown as string)).toBe('')
1343+
expect(insertLineBreaks(42 as unknown as string)).toBe('')
1344+
expect(insertLineBreaks({} as unknown as string)).toBe('')
1345+
})
1346+
1347+
it('returns the original text when maxCharactersPerLine is not a positive integer', () => {
1348+
expect(insertLineBreaks('hello world', 0)).toBe('hello world')
1349+
expect(insertLineBreaks('hello world', -1)).toBe('hello world')
1350+
expect(insertLineBreaks('hello world', 2.5)).toBe('hello world')
1351+
expect(insertLineBreaks('hello world', Number.NaN)).toBe('hello world')
1352+
})
1353+
1354+
it('returns the same text when it already fits on one line', () => {
1355+
expect(insertLineBreaks('hello world', 24)).toBe('hello world')
1356+
})
1357+
1358+
it('breaks text into multiple lines on word boundaries', () => {
1359+
expect(insertLineBreaks('hello world again', 11)).toBe('hello world\nagain')
1360+
})
1361+
1362+
it('preserves a single space between words when collapsing whitespace', () => {
1363+
expect(insertLineBreaks('hello world', 24)).toBe('hello world')
1364+
})
1365+
1366+
it('ignores leading and trailing whitespace', () => {
1367+
expect(insertLineBreaks(' hello world ', 24)).toBe('hello world')
1368+
})
1369+
1370+
it('handles tabs and newlines as whitespace separators', () => {
1371+
expect(insertLineBreaks('hello\tworld\nagain', 11)).toBe('hello world\nagain')
1372+
})
1373+
1374+
it('starts a new line when adding a word would exceed the limit', () => {
1375+
expect(insertLineBreaks('one two three', 7)).toBe('one two\nthree')
1376+
})
1377+
1378+
it('keeps a word on the current line when it exactly matches the limit', () => {
1379+
expect(insertLineBreaks('abc def', 7)).toBe('abc def')
1380+
})
1381+
1382+
it('splits a long token into chunks when it exceeds the limit', () => {
1383+
expect(insertLineBreaks('abcdefghijkl', 5)).toBe('abcde\nfghij\nkl')
1384+
})
1385+
1386+
it('pushes the current line before splitting a long token', () => {
1387+
expect(insertLineBreaks('hello abcdefghij', 5)).toBe('hello\nabcde\nfghij')
1388+
})
1389+
1390+
it('continues building lines after a split long token', () => {
1391+
expect(insertLineBreaks('abcdefghij klm nop', 5)).toBe('abcde\nfghij\nklm\nnop')
1392+
})
1393+
1394+
it('handles multiple consecutive long tokens', () => {
1395+
expect(insertLineBreaks('abcdefghijk lmnopqrs', 4)).toBe('abcd\nefgh\nijk\nlmno\npqrs')
1396+
})
1397+
1398+
it('returns an empty string for an empty input string', () => {
1399+
expect(insertLineBreaks('', 24)).toBe('')
1400+
})
1401+
1402+
it('returns an empty string for a whitespace-only string', () => {
1403+
expect(insertLineBreaks(' ', 24)).toBe('')
1404+
expect(insertLineBreaks('\n\t ', 24)).toBe('')
1405+
})
1406+
1407+
it('uses the default maxCharactersPerLine value when omitted', () => {
1408+
expect(insertLineBreaks('one two three four five six')).toBe('one two three four five\nsix')
1409+
})
1410+
})
1411+
1412+
describe('applyEllipsis', () => {
1413+
it('returns an empty string when text is not a string', () => {
1414+
expect(applyEllipsis(null as unknown as string)).toBe('')
1415+
expect(applyEllipsis(undefined as unknown as string)).toBe('')
1416+
expect(applyEllipsis(42 as unknown as string)).toBe('')
1417+
expect(applyEllipsis({} as unknown as string)).toBe('')
1418+
})
1419+
1420+
it('returns the original text when maxLength is not a positive integer', () => {
1421+
expect(applyEllipsis('touching grass', 0)).toBe('touching grass')
1422+
expect(applyEllipsis('touching grass', -1)).toBe('touching grass')
1423+
expect(applyEllipsis('touching grass', 2.5)).toBe('touching grass')
1424+
expect(applyEllipsis('touching grass', Number.NaN)).toBe('touching grass')
1425+
})
1426+
1427+
it('returns the original text when its length is less than maxLength', () => {
1428+
expect(applyEllipsis('grass', 10)).toBe('grass')
1429+
})
1430+
1431+
it('returns the original text when its length is equal to maxLength', () => {
1432+
expect(applyEllipsis('grass', 5)).toBe('grass')
1433+
})
1434+
1435+
it('truncates the text and appends an ellipsis when its length exceeds maxLength', () => {
1436+
expect(applyEllipsis('grass touching', 5)).toBe('grass...')
1437+
})
1438+
1439+
it('uses the default maxLength when omitted', () => {
1440+
const text = 'n'.repeat(46)
1441+
expect(applyEllipsis(text)).toBe(`${'n'.repeat(45)}...`)
1442+
})
1443+
1444+
it('returns an empty string for an empty input string', () => {
1445+
expect(applyEllipsis('')).toBe('')
1446+
})
1447+
1448+
it('handles maxLength equal to 1', () => {
1449+
expect(applyEllipsis('grass', 1)).toBe('g...')
1450+
})
1451+
1452+
it('preserves whitespace within the truncated portion', () => {
1453+
expect(applyEllipsis('you need to touch grass', 13)).toBe('you need to t...')
1454+
})
1455+
})

0 commit comments

Comments
 (0)