Skip to content

Commit

Permalink
feat: interactive charts
Browse files Browse the repository at this point in the history
  • Loading branch information
8lane committed Oct 24, 2024
1 parent 0b0e45b commit b60b307
Show file tree
Hide file tree
Showing 8 changed files with 3,326 additions and 309 deletions.
3,261 changes: 3,070 additions & 191 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"next": "^14.2.14",
"nuqs": "^1.17.2",
"pino": "^8.20.0",
"plotly.js": "^2.35.2",
"prism-react-renderer": "^2.3.1",
"prismjs": "^1.29.0",
"react": "^18.3.1",
Expand All @@ -53,6 +54,7 @@
"react-leaflet": "^4.2.1",
"react-leaflet-custom-control": "^1.4.0",
"react-markdown": "^8.0.7",
"react-plotly.js": "^2.6.0",
"react-use": "^17.5.0",
"rehype-raw": "^6.1.1",
"rehype-slug": "^5.1.0",
Expand Down Expand Up @@ -80,6 +82,7 @@
"@types/node": "^20.12.7",
"@types/react": "^18.3.7",
"@types/react-dom": "^18.3.0",
"@types/react-plotly.js": "^2.6.3",
"@types/whatwg-fetch": "^0.0.33",
"@typescript-eslint/eslint-plugin": "^7.6.0",
"@unlighthouse/cli": "^0.13.1",
Expand Down
105 changes: 69 additions & 36 deletions src/app/components/cms/Chart/Chart.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,21 @@ jest.mock('@/api/requests/charts/getCharts')

const getChartsMock = jest.mocked(getCharts)

test('renders the chart correctly when successful', async () => {
getChartsMock.mockResolvedValueOnce({
success: true,
data: {
chart: 'mock-chart',
alt_text: 'alt text for chart',
last_updated: '2023-05-10T15:18:06.939535+01:00',
figure: { data: [], layout: {} },
},
console.error = jest.fn()

const chartDataMocks = ['mock-chart-narrow', 'mock-chart-wide', 'mock-chart-half', 'mock-chart-third']

test('renders a narrow chart correctly', async () => {
chartDataMocks.forEach((chart) => {
getChartsMock.mockResolvedValueOnce({
success: true,
data: {
chart,
alt_text: 'alt text for chart',
last_updated: '2023-05-10T15:18:06.939535+01:00',
figure: { data: [], layout: {} },
},
})
})

const data: ComponentProps<typeof Chart>['data'] = {
Expand Down Expand Up @@ -84,7 +90,7 @@ test('renders the chart correctly when successful', async () => {

expect(getByAltText('alt text for chart - Refer to tabular data.')).toHaveAttribute(
'src',
'data:image/svg+xml;utf8,mock-chart'
'data:image/svg+xml;utf8,mock-chart-narrow'
)
})

Expand All @@ -93,14 +99,16 @@ test('renders the chart by geography and geography type when both are present in
.mocked(getSearchParams)
.mockReturnValueOnce(new URL('http://localhost?areaType=UKHSA+Region&areaName=North+East').searchParams)

getChartsMock.mockResolvedValueOnce({
success: true,
data: {
chart: 'mock-chart',
alt_text: 'alt text for chart',
last_updated: '2023-05-10T15:18:06.939535+01:00',
figure: { data: [], layout: {} },
},
chartDataMocks.forEach((chart) => {
getChartsMock.mockResolvedValueOnce({
success: true,
data: {
chart,
alt_text: 'alt text for chart',
last_updated: '2023-05-10T15:18:06.939535+01:00',
figure: { data: [], layout: {} },
},
})
})

const data: ComponentProps<typeof Chart>['data'] = {
Expand Down Expand Up @@ -145,28 +153,21 @@ test('renders the chart by geography and geography type when both are present in

expect(getByAltText('alt text for chart - Refer to tabular data.')).toHaveAttribute(
'src',
'data:image/svg+xml;utf8,mock-chart'
'data:image/svg+xml;utf8,mock-chart-narrow'
)
})

test('full width charts should also have an acompanying narrow version for mobile viewports', async () => {
getChartsMock.mockResolvedValueOnce({
success: true,
data: {
chart: 'mock-chart-narrow',
alt_text: 'alt text for chart',
last_updated: '2023-05-10T15:18:06.939535+01:00',
figure: { data: [], layout: {} },
},
})
getChartsMock.mockResolvedValueOnce({
success: true,
data: {
chart: 'mock-chart-wide',
alt_text: 'alt text for chart',
last_updated: '2023-05-10T15:18:06.939535+01:00',
figure: { data: [], layout: {} },
},
chartDataMocks.forEach((chart) => {
getChartsMock.mockResolvedValueOnce({
success: true,
data: {
chart,
alt_text: 'alt text for chart',
last_updated: '2023-05-10T15:18:06.939535+01:00',
figure: { data: [], layout: {} },
},
})
})

const data: ComponentProps<typeof Chart>['data'] = {
Expand All @@ -189,6 +190,38 @@ test('full width charts should also have an acompanying narrow version for mobil
expect(getByTestId('chart-src-min-768')).toHaveAttribute('media', '(min-width: 768px)')
})

test('landing page half width charts should also have an acompanying narrow version for mobile viewports', async () => {
chartDataMocks.forEach((chart) => {
getChartsMock.mockResolvedValueOnce({
success: true,
data: {
chart,
alt_text: 'alt text for chart',
last_updated: '2023-05-10T15:18:06.939535+01:00',
figure: { data: [], layout: {} },
},
})
})

const data: ComponentProps<typeof Chart>['data'] = {
x_axis: null,
y_axis: null,
chart: [],
body: 'COVID-19 chart description.',
tag_manager_event_id: '',
title: '',
headline_number_columns: [],
}

const { getByAltText, getByTestId } = render((await Chart({ data, size: 'half' })) as ReactElement)

expect(getByAltText('alt text for chart - Refer to tabular data.')).toHaveAttribute(
'src',
'data:image/svg+xml;utf8,mock-chart-half'
)
expect(getByTestId('chart-src-min-1200')).toHaveAttribute('srcset', 'data:image/svg+xml;utf8,mock-chart-third')
})

test('renders a fallback message when the chart requests fail', async () => {
const url = 'http://localhost?areaType=UKHSA+Region&areaName=North+East'
jest.mocked(useSearchParams).mockReturnValueOnce(new ReadonlyURLSearchParams(new URL(url).searchParams))
Expand Down
187 changes: 106 additions & 81 deletions src/app/components/cms/Chart/Chart.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import dynamic from 'next/dynamic'
import { Suspense } from 'react'
import { z } from 'zod'

import { WithChartCard, WithChartHeadlineAndTrendCard, WithSimplifiedChartCardAndLink } from '@/api/models/cms/Page'
import { getCharts } from '@/api/requests/charts/getCharts'
import { flags } from '@/app/constants/flags.constants'
import { getAreaSelector } from '@/app/hooks/getAreaSelector'
import { getPathname } from '@/app/hooks/getPathname'
import { getServerTranslation } from '@/app/i18n'
import { getChartSvg } from '@/app/utils/chart.utils'
import { getFeatureFlag } from '@/app/utils/flags.utils'
import { chartSizes } from '@/config/constants'

import { ChartEmpty } from '../ChartEmpty/ChartEmpty'

interface ChartProps {
/* Enable interactive plotly.js */
enableInteractive?: boolean

/* Request metadata from the CMS required to fetch from the headlines api */
data:
| z.infer<typeof WithChartHeadlineAndTrendCard>['value']
Expand All @@ -21,8 +28,50 @@ interface ChartProps {
size: 'narrow' | 'wide' | 'half' | 'third'
}

export async function Chart({ data, size }: ChartProps) {
const { t } = await getServerTranslation('common')
const createStaticChart = ({
size,
charts: { wideChart, halfChart, thirdChart, narrowChart },
areaName,
altText,
}: {
size: ChartProps['size']
charts: Record<string, string>
areaName: string | null
altText: string
}) => {
const onLandingPage = size === 'third' || size === 'half'

return (
<picture data-testid="chart" data-location={areaName}>
{size === 'wide' && (
<source
media="(min-width: 768px)"
srcSet={`data:image/svg+xml;utf8,${getChartSvg(wideChart)}`}
data-testid="chart-src-min-768"
/>
)}
{size === 'half' && (
<source
media="(min-width: 1200px)"
srcSet={`data:image/svg+xml;utf8,${getChartSvg(halfChart)}`}
data-testid="chart-src-min-1200"
/>
)}
<img
alt={altText}
src={`data:image/svg+xml;utf8,${getChartSvg(onLandingPage ? thirdChart : narrowChart)}`}
className="w-full"
/>
</picture>
)
}

export async function Chart({ data, size, enableInteractive = true }: ChartProps) {
const [{ enabled: interactiveChartsFlagEnabled }, { t }] = await Promise.all([
getFeatureFlag(flags.interactiveCharts),
getServerTranslation('common'),
])

const { chart, x_axis, y_axis } = data

const pathname = getPathname()
Expand All @@ -34,92 +83,68 @@ export async function Chart({ data, size }: ChartProps) {
geography: areaName ?? plot.value.geography,
}))

// Collect all chart svg's mobile first using the narrow aspect ratio
const chartRequests = [
getCharts({
plots,
x_axis,
y_axis,
chart_width: chartSizes.narrow.width,
chart_height: chartSizes.narrow.height,
}),
]
const request = (chart_width: number, chart_height: number) =>
getCharts({ plots, x_axis, y_axis, chart_width, chart_height })

// The concept of a "wide" chart only applies to the desktop viewport
if (size === 'wide') {
chartRequests.push(
getCharts({
plots,
x_axis,
y_axis,
chart_width: chartSizes.wide.width,
chart_height: chartSizes.wide.height,
})
)
}
// Collect all chart svg's for all desired sizes
const topicPageRequests = [
request(chartSizes.narrow.width, chartSizes.narrow.height),
request(chartSizes.wide.width, chartSizes.wide.height),
]

// All landing page charts loading small width first
const landingChartRequests = [
getCharts({
plots,
x_axis,
y_axis,
chart_width: chartSizes.third.width,
chart_height: chartSizes.third.height,
}),
const landingPageRequests = [
request(chartSizes.third.width, chartSizes.third.height),
request(chartSizes.half.width, chartSizes.half.height),
]

// Wider landing page charts where required
if (size === 'half') {
landingChartRequests.push(
getCharts({
plots,
x_axis,
y_axis,
chart_width: chartSizes.half.width,
chart_height: chartSizes.half.height,
})
)
}
const [narrowChartResponse, wideChartResponse] = await Promise.all(topicPageRequests)
const [thirdChartResponse, halfChartResponse] = await Promise.all(landingPageRequests)

const [narrowChartResponse, wideChartResponse] = await Promise.all(chartRequests)
const [thirdChartResponse, halfChartResponse] = await Promise.all(landingChartRequests)
if (
!narrowChartResponse.success ||
!wideChartResponse.success ||
!thirdChartResponse.success ||
!halfChartResponse.success
) {
return <ChartEmpty resetHref={pathname} />
}

const onLandingPage = size === 'third' || size === 'half'
const {
data: { alt_text: alt, figure },
} = narrowChartResponse

const staticChart = createStaticChart({
size,
charts: {
wideChart: wideChartResponse.data.chart,
thirdChart: thirdChartResponse.data.chart,
halfChart: halfChartResponse.data.chart,
narrowChart: narrowChartResponse.data.chart,
},
areaName,
altText: t('cms.blocks.chart.alt', { body: alt }),
})

// Lazy load the interactive chart component (and all associated plotly.js code)
const ChartInteractive = dynamic(() => import('../ChartInteractive/ChartInteractive'), {
ssr: false,
loading: () => staticChart, // Show the static svg chart whilst this chunk is being loaded
})

// Return static charts locally as our mocks don't currently provide the plotly layout & data json.
// Update the mocks to include this, and then remove the below condition to enable interactive charts locally.
if (!process.env.API_URL.includes('ukhsa-dashboard.data.gov.uk')) {
return staticChart
}

if (narrowChartResponse.success) {
const {
data: { chart: narrowChart, alt_text: alt },
} = narrowChartResponse

const wideChart = wideChartResponse && wideChartResponse.success && wideChartResponse.data.chart
const thirdChart = (thirdChartResponse && thirdChartResponse.success && thirdChartResponse.data.chart) || ''
const halfChart = halfChartResponse && halfChartResponse.success && halfChartResponse.data.chart

return (
<picture data-testid="chart" data-location={areaName}>
{wideChart && (
<source
media="(min-width: 768px)"
srcSet={`data:image/svg+xml;utf8,${getChartSvg(wideChart)}`}
data-testid="chart-src-min-768"
/>
)}
{halfChart && (
<source
media="(min-width: 1200px)"
srcSet={`data:image/svg+xml;utf8,${getChartSvg(halfChart)}`}
data-testid="chart-src-min-768"
/>
)}
<img
alt={t('cms.blocks.chart.alt', { body: alt })}
src={`data:image/svg+xml;utf8,${getChartSvg(onLandingPage ? thirdChart : narrowChart)}`}
className="w-full"
/>
</picture>
)
// Show static chart when interactive charts are disabled (i.e. landing page) or when feature flag is off
if (!enableInteractive || !interactiveChartsFlagEnabled) {
return staticChart
}

return <ChartEmpty resetHref={pathname} />
return (
<Suspense fallback={staticChart}>
<ChartInteractive fallbackUntilLoaded={staticChart} figure={{ frames: [], ...figure }} />
</Suspense>
)
}
Loading

0 comments on commit b60b307

Please sign in to comment.