diff --git a/server/models/views/lib/latest-levels.js b/server/models/views/lib/latest-levels.js index 718d4d49b..37fac6a2a 100644 --- a/server/models/views/lib/latest-levels.js +++ b/server/models/views/lib/latest-levels.js @@ -2,6 +2,19 @@ const { formatElapsedTime } = require('../../../util') const WARNING_THRESHOLD_TYPES = ['FW RES FW', 'FW ACT FW', 'FW ACTCON FW'] +function adjustThresholdValue (value, stageDatum, subtract, postProcess) { + if (postProcess) { + if (stageDatum && stageDatum > 0) { + value -= stageDatum + } else if (stageDatum <= 0 && subtract && subtract > 0) { + value -= subtract + } else { + return parseFloat(value).toFixed(2) + } + } + return parseFloat(value).toFixed(2) +} + function getThresholdsForTargetArea (thresholds) { const filteredThresholds = thresholds.filter(threshold => threshold.status !== 'Closed' && @@ -9,9 +22,19 @@ function getThresholdsForTargetArea (thresholds) { ) const warningThresholds = findPrioritisedThresholds(filteredThresholds, WARNING_THRESHOLD_TYPES) + return warningThresholds.map(threshold => { threshold.formatted_time = formatElapsedTime(threshold.value_timestamp) threshold.isSuspendedOrOffline = threshold.status === 'Suspended' || (threshold.status === 'Active' && threshold.latest_level === null) + + // Use adjustThresholdValue for threshold_value adjustment + threshold.threshold_value = adjustThresholdValue( + threshold.threshold_value, + threshold.stage_datum, + threshold.subtract, + threshold.post_process + ) + return threshold }) } diff --git a/server/models/views/station.js b/server/models/views/station.js index e42d27d06..fb9cfd5d3 100644 --- a/server/models/views/station.js +++ b/server/models/views/station.js @@ -12,9 +12,11 @@ const bannerIconId3 = 3 const outOfDateMax = 5 const dataStartDateTimeDaysToSubtract = 5 +const TOP_OF_NORMAL_RANGE = 'Top of normal range' + class ViewModel { constructor (options) { - const { station, telemetry, forecast, imtdThresholds, impacts, river, warningsAlerts } = options + const { station, telemetry, forecast, imtdThresholds, impacts, river, warningsAlerts, requestUrl } = options this.station = new Station(station) this.station.riverNavigation = river @@ -39,7 +41,6 @@ class ViewModel { const numSevereWarnings = warningsAlertsGroups['3'] ? warningsAlertsGroups['3'].length : 0 // Determine appropriate warning/alert text for banner - this.banner = numAlerts || numWarnings || numSevereWarnings switch (numAlerts) { @@ -230,12 +231,12 @@ class ViewModel { } this.metaDescription = `Check the latest recorded ${stationType.toLowerCase()} level and recent 5-day trend at ${stationLocation}` - // Thresholds + // Array to hold thresholds let thresholds = [] + // Check if recent value exists and add it to thresholds if (this.station.recentValue && !this.station.recentValue.err) { const tVal = this.station.type !== 'c' && this.station.recentValue._ <= 0 ? 0 : this.station.recentValue._.toFixed(2) - thresholds.push({ id: 'latest', value: tVal, @@ -243,6 +244,7 @@ class ViewModel { shortname: '' }) } + // Add the highest level threshold if available if (this.station.porMaxValue) { thresholds.push({ id: 'highest', @@ -253,7 +255,6 @@ class ViewModel { shortname: 'Highest level on record' }) } - this.imtdThresholds = imtdThresholds?.length > 0 ? filterImtdThresholds(imtdThresholds) : [] @@ -265,9 +266,33 @@ class ViewModel { this.station.post_process, this.station.percentile5 ) - thresholds.push(...processedImtdThresholds) + if (this.station.percentile5) { + // Only push typical range if it has a percentil5 + thresholds.push({ + id: 'pc5', + value: this.station.percentile5, + description: 'This is the top of the normal range', + shortname: TOP_OF_NORMAL_RANGE + }) + } + + // Handle chartThreshold: add tidThreshold if a valid tid is present; if not, fallback to 'pc5'; if 'pc5' is unavailable, use 'alertThreshold' with "Top of normal range" description. + // Extract tid from request URL if valid + let tid = null + try { + tid = requestUrl?.startsWith('http') ? new URL(requestUrl).searchParams.get('tid') : null + } catch (e) { + console.error('Invalid request URL:', e) + } + + // Retrieve the applicable threshold for chartThreshold + const chartThreshold = [getThresholdByThresholdId(tid, imtdThresholds, thresholds)].filter(Boolean) + + // Set chartThreshold property + this.chartThreshold = chartThreshold + // Add impacts if (impacts.length > 0) { this.station.hasImpacts = true @@ -354,7 +379,6 @@ class ViewModel { this.zoom = 14 // Forecast Data Calculations - let forecastData if (isForecast) { this.isFfoi = isForecast @@ -424,4 +448,28 @@ function telemetryForecastBuilder (telemetryRawData, forecastRawData, stationTyp } } +// Function to retrieve a threshold by tid or fall back to 'pc5' or 'alertThreshold' +const getThresholdByThresholdId = (tid, imtdThresholds, thresholds) => { + // Check if a threshold exists based on tid + const tidThreshold = tid && imtdThresholds?.find(thresh => thresh.station_threshold_id === tid) + if (tidThreshold) { + return { + id: tidThreshold.station_threshold_id, + value: Number(tidThreshold.value).toFixed(2), + description: `${tidThreshold.value}m ${tidThreshold.ta_name || ''}`, + shortname: tidThreshold.ta_name || 'Target Area Threshold' + } + } + + // Fallback to 'pc5' if present, else look for 'alertThreshold' + const pc5Threshold = thresholds.find(t => t.id === 'pc5') + if (pc5Threshold) { + return pc5Threshold + } + + // Fallback to 'alertThreshold' if description includes 'Top of normal range' + const alertThreshold = thresholds.find(t => t.id === 'alertThreshold' && t.description.includes(TOP_OF_NORMAL_RANGE)) + return alertThreshold ? { ...alertThreshold, shortname: TOP_OF_NORMAL_RANGE } : null +} + module.exports = ViewModel diff --git a/server/routes/station.js b/server/routes/station.js index 23664da2e..0162af6a5 100644 --- a/server/routes/station.js +++ b/server/routes/station.js @@ -55,6 +55,9 @@ module.exports = { request.server.methods.flood.getRiverStationByStationId(id, direction) ]) + // const requestUrl = request.url.href + const requestUrl = request.url.toString() + if (station.status === 'Closed') { const river = [] const model = new ViewModel({ station, telemetry, imtdThresholds, impacts, river, warningsAlerts }) @@ -68,11 +71,11 @@ module.exports = { // Forecast station const values = await request.server.methods.flood.getStationForecastData(station.wiski_id) const forecast = { forecastFlag, values } - const model = new ViewModel({ station, telemetry, forecast, imtdThresholds, impacts, river, warningsAlerts }) + const model = new ViewModel({ station, telemetry, forecast, imtdThresholds, impacts, river, warningsAlerts, requestUrl }) return h.view('station', { model }) } else { // Non-forecast Station - const model = new ViewModel({ station, telemetry, imtdThresholds, impacts, river, warningsAlerts }) + const model = new ViewModel({ station, telemetry, imtdThresholds, impacts, river, warningsAlerts, requestUrl }) return h.view('station', { model }) } }, diff --git a/server/src/js/components/chart.js b/server/src/js/components/chart.js index 7cc43d4ad..54e23d148 100644 --- a/server/src/js/components/chart.js +++ b/server/src/js/components/chart.js @@ -776,24 +776,25 @@ function LineChart (containerId, stationId, data, options = {}) { .attr('class', 'threshold__line') .attr('aria-hidden', true) .attr('x2', xScale(xExtent[1])).attr('y2', 0) - - // Label - const copy = `${threshold.level}m ${threshold.name}`.match(/[\s\S]{1,35}(?!\S)/g, '$&\n') - const label = thresholdContainer.append('g') - .attr('class', 'threshold-label') + // Construct label text and split into lines of up to 35 characters + const thresholdLabel = `${threshold.level}m ${threshold.name}` + const labelSegments = thresholdLabel.match(/.{1,35}(\s|$)/g).map(line => line.trim()) + const label = thresholdContainer.append('g').attr('class', 'threshold-label') const path = label.append('path') .attr('aria-hidden', true) .attr('class', 'threshold-label__bg') const text = label.append('text') .attr('class', 'threshold-label__text') text.append('tspan').attr('font-size', 0).text('Threshold: ') - copy.map((l, i) => text.append('tspan').attr('x', 10).attr('y', (i + 1) * 22).text(l.trim())) + // Add each line of the split text (up to 35 characters per line) as separate tspans + labelSegments.forEach((line, i) => { + text.append('tspan').attr('x', 10).attr('y', (i + 1) * 22).text(line.trim()) + }) const textWidth = Math.round(text.node().getBBox().width) const textHeight = Math.round(text.node().getBBox().height) - path.attr('d', `m-0.5,-0.5 l${textWidth + 20},0 l0,36 l-${((textWidth + 20) / 2) - 7.5},0 l-7.5,7.5 l-7.5,-7.5 l-${((textWidth + 20) / 2) - 7.5},0 l0,-36 l0,0`) + path.attr('d', `m-0.5,-0.5 l${textWidth + 20},0 l0,${19 + textHeight} l-${((textWidth + 20) / 2) - 7.5},0 l-7.5,7.5 l-7.5,-7.5 l-${((textWidth + 20) / 2) - 7.5},0 l0,-${19 + textHeight} l0,0`) label.attr('transform', `translate(${Math.round(width / 2 - ((textWidth + 20) / 2))}, -${29 + textHeight})`) - - // Remove button + const remove = thresholdContainer.append('a') .attr('role', 'button') .attr('class', 'threshold__remove') @@ -1430,16 +1431,25 @@ if (document.getElementById('bar-chart')) { window.flood.charts.createBarChart('bar-chart', window.flood.model.stationId, window.flood.model.telemetry) } -// Line chart -if (document.getElementById('line-chart')) { - const lineChart = window.flood.charts.createLineChart('line-chart', window.flood.model.id, window.flood.model.telemetry) - const thresholdId = 'threshold-pc5' - const threshold = document.querySelector(`[data-id="${thresholdId}"]`) +// Line chart setup to display thresholds based on chartThreshold configuration ----- +function addThresholdToChart (lineChart, threshold) { if (threshold) { lineChart.addThreshold({ - id: thresholdId, - name: threshold.getAttribute('data-name'), - level: Number(threshold.getAttribute('data-level')) + id: threshold.id, + name: threshold.shortname, + level: Number(threshold.value) }) + } else { + console.error('No valid threshold found to add to the chart.') } } + +// Initialize the line chart and set thresholds using chartThreshold from ViewModel +if (document.getElementById('line-chart')) { + const lineChart = window.flood.charts.createLineChart('line-chart', window.flood.model.id, window.flood.model.telemetry) + + // Iterate through each threshold in chartThreshold and add it to the chart + window.flood.model.chartThreshold.forEach(threshold => { + addThresholdToChart(lineChart, threshold) + }) +} diff --git a/server/src/sass/components/_latest-levels-box.scss b/server/src/sass/components/_latest-levels-box.scss index 98886a2b5..599291e5e 100644 --- a/server/src/sass/components/_latest-levels-box.scss +++ b/server/src/sass/components/_latest-levels-box.scss @@ -13,6 +13,14 @@ text-transform: uppercase; background-color: #1C70B8; color: white; + word-spacing: 0.15em; + + // Add contrast-specific styles for high contrast mode + @media (forced-colors: active) { + background-color: Canvas; // Ensures light background in contrast mode + border: 2px solid ButtonText; // Adds a visible border in high contrast + color: ButtonText; // Uses the system's high-contrast text color + } } &__supplementary { @include govuk-font($size: 16); diff --git a/server/src/sass/objects/_buttons.scss b/server/src/sass/objects/_buttons.scss index 2f350f634..cbbd584f5 100644 --- a/server/src/sass/objects/_buttons.scss +++ b/server/src/sass/objects/_buttons.scss @@ -143,4 +143,4 @@ button.defra-button-secondary { margin-bottom: -4px; top: 0px; } -} +} \ No newline at end of file diff --git a/server/views/partials/latest-levels.html b/server/views/partials/latest-levels.html index 0d9386ef1..c00f58800 100644 --- a/server/views/partials/latest-levels.html +++ b/server/views/partials/latest-levels.html @@ -3,21 +3,22 @@
The {{ warnings.river_name }} level at {{ warnings.agency_name }} is currently unavailable.
{% else %}The {{ warnings.river_name }} level at {{ warnings.agency_name }} was {{ warnings.latest_level | toFixed(2) }} metres. Property flooding is possible when it goes above {{ warnings.threshold_value | toFixed(2) }} metres. {% if model.latestLevels.length > 1 %} - Monitor the {{ warnings.river_name }} level at {{ warnings.agency_name }} + Monitor the {{ warnings.river_name }} level at {{ warnings.agency_name }} {% endif %}
{% if model.latestLevels.length == 1 %}- Monitor the latest{% if model.latestLevels.length > 1 %} {{ warnings.river_name }}{% endif %} level at {{ warnings.agency_name }} + Monitor the latest{% if model.latestLevels.length > 1 %} {{ warnings.river_name }}{% endif %} level at {{ warnings.agency_name }}
{% endif %} {% endif %}{% if model.latestLevels.length > 1 %}These levels{% else %}This level{% endif %} will update automatically
diff --git a/server/views/target-area.html b/server/views/target-area.html index d5a2097b2..039ffdeae 100644 --- a/server/views/target-area.html +++ b/server/views/target-area.html @@ -51,22 +51,35 @@{% include "partials/sign-up-for-flood-warnings.html" %}
+ - - -{{ model.areaDescription | safe }}
+ + {% if model.severity %} ++ + Give feedback on this flood warning information + +
+ {% endif %} + {% if model.latestLevels and model.latestLevels.length > 0 and model.latestLevels.length <= 4 %} {% include "partials/latest-levels.html" %} @@ -75,22 +88,9 @@- Find other river and sea levels - -
- - - {% include "partials/sign-up-for-flood-warnings.html" %} - - - {% if model.severity %} -- Could this information be better? - - Tell us how to improve it. + Find a river, sea, groundwater or rainfall level in this area
- {% endif %} {% include "partials/context-footer.html" %} @@ -111,7 +111,7 @@The River Derwent level at Derby St Marys was 0.54 metres. Property flooding is possible when it goes above 3.30 metres.') - Code.expect(response.payload).to.contain('Monitor the latest level at Derby St Marys') + Code.expect(response.payload).to.contain('Monitor the latest level at Derby St Marys') Code.expect(response.payload).to.match(/
The River Pinn level at Eastcote Road was 0.35 metres. Property flooding is possible when it goes above 1.40 metres.') Code.expect(response.payload).to.contain('
The River Pinn level at Avenue Road was 0.18 metres. Property flooding is possible when it goes above 1.46 metres.') - Code.expect(response.payload).to.contain('Monitor the River Pinn level at Avenue Road') + Code.expect(response.payload).to.contain('Monitor the River Pinn level at Avenue Road') Code.expect(response.payload).to.contain('
The River Pinn level at Moss Close was 0.13 metres. Property flooding is possible when it goes above 1.15 metres.')
- Code.expect(response.payload).to.contain('Monitor the River Pinn level at Moss Close')
+ Code.expect(response.payload).to.contain('Monitor the River Pinn level at Moss Close')
})
lab.test('Check flood severity banner link for Flood warning', async () => {
const floodService = require('../../server/services/flood')
@@ -286,7 +287,7 @@ lab.experiment('Target-area tests', () => {
Code.expect(response.statusCode).to.equal(200)
const root = parse(response.payload)
- Code.expect(response.payload).to.contain('Find other river and sea levels')
+ Code.expect(response.payload).to.contain('Find a river, sea, groundwater or rainfall level in this area')
Code.expect(response.payload).to.contain('')
const relatedContentLinks = root.querySelectorAll('.defra-related-items a')
@@ -357,7 +358,7 @@ lab.experiment('Target-area tests', () => {
// context footer check
validateFooterPresent(response)
Code.expect(response.payload).to.contain('Severe flood warning for Upper River Derwent, Stonethwaite Beck and Derwent Water')
- Code.expect(response.payload).to.contain('Find other river and sea levels')
+ Code.expect(response.payload).to.contain('Find a river, sea, groundwater or rainfall level in this area')
const anchorFound = root.querySelectorAll('a').some(a =>
a.attributes.href === '/river-and-sea-levels/target-area/011WAFDW'
@@ -562,6 +563,7 @@ lab.experiment('Target-area tests', () => {
Code.expect(response.payload).to.contain(' The River Pinn level at Avenue Road is currently unavailable. This level will update automatically The River Pinn level at Moss Close is currently unavailable. This level will update automatically The River Pinn level at Eastcote Road was 0.35 metres. Property flooding is possible when it goes above 1.40 metres.')
Code.expect(response.payload).to.contain(' The River Pinn level at Avenue Road is currently unavailable. These levels will update automatically The River Pinn level at Eastcote Road was 0.35 metres. Property flooding is possible when it goes above 1.40 metres.')
+ Code.expect(response.payload).to.contain(' This level will update automatically The River Pinn level at Eastcote Road was 0.35 metres. Property flooding is possible when it goes above 1.40 metres.')
Code.expect(response.payload).to.contain(' The River Pinn level at Avenue Road is currently unavailable. The River Welsh station level is currently unavailable. These levels will update automatically The River Pinn level at Eastcote Road was 0.35 metres. Property flooding is possible when it goes above 1.40 metres.')
Code.expect(response.payload).to.contain(' The River Pinn level at Avenue Road is currently unavailable. The River Test level at Test Road is currently unavailable. These levels will update automatically The River Welsh level at Welsh Station was 0.35 metres. Property flooding is possible when it goes above 1.40 metres.\n \n Latest level
')
Code.expect(response.payload).to.contain('Latest level
')
Code.expect(response.payload).to.contain('Latest levels
')
Code.expect(response.payload).to.contain('Latest level
')
Code.expect(response.payload).to.contain('Latest level
')
Code.expect(response.payload).to.contain('
This level will update automatically
') }) })