diff --git a/server/models/views/lib/find-min-threshold.js b/server/models/views/lib/find-min-threshold.js index cf0909c36..360990474 100644 --- a/server/models/views/lib/find-min-threshold.js +++ b/server/models/views/lib/find-min-threshold.js @@ -31,7 +31,7 @@ function filterImtdThresholds (imtdThresholds) { return { alert: minObjectA ? minObjectA.value : null, - warning: minObjectW ? minObjectW.value : null + warning: minObjectW || null } } 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/lib/process-imtd-thresholds.js b/server/models/views/lib/process-imtd-thresholds.js index 0d84f7e8f..dd8781d03 100644 --- a/server/models/views/lib/process-imtd-thresholds.js +++ b/server/models/views/lib/process-imtd-thresholds.js @@ -1,3 +1,4 @@ +const SEVERE_FLOOD_WARNING_THRESHOLD = 3 function processThreshold (threshold, stationStageDatum, stationSubtract, postProcess) { if (threshold) { if (postProcess) { @@ -14,30 +15,66 @@ function processThreshold (threshold, stationStageDatum, stationSubtract, postPr return null } -function processImtdThresholds (imtdThresholds, stationStageDatum, stationSubtract, postProcess) { +function processImtdThresholds (imtdThresholds, stationStageDatum, stationSubtract, postProcess, pc5) { const thresholds = [] - const imtdThresholdWarning = processThreshold(imtdThresholds?.warning, stationStageDatum, stationSubtract, postProcess) + const imtdThresholdWarning = calculateWarningThreshold(imtdThresholds, stationStageDatum, stationSubtract, postProcess) + const imtdThresholdAlert = calculateAlertThreshold(imtdThresholds, stationStageDatum, stationSubtract, postProcess, pc5) + if (imtdThresholdWarning) { - // Correct threshold value if value > zero (Above Ordnance Datum) [FSR-595] + thresholds.push(imtdThresholdWarning) + } + + if (imtdThresholdAlert) { + thresholds.push(imtdThresholdAlert) + } else if (pc5) { thresholds.push({ + id: 'pc5', + description: 'Top of normal range. Low lying land flooding possible above this level', + shortname: 'Top of normal range', + value: pc5 + }) + } else { return thresholds } + + return thresholds +} + +function calculateWarningThreshold (imtdThresholds, stationStageDatum, stationSubtract, postProcess) { + const imtdThresholdWarning = processThreshold(imtdThresholds?.warning?.value, stationStageDatum, stationSubtract, postProcess) + + if (imtdThresholdWarning) { + const warningType = imtdThresholds.warning.severity_value === SEVERE_FLOOD_WARNING_THRESHOLD + ? 'Severe flood warning' + : 'Flood warning' + + return { id: 'warningThreshold', - description: 'Property flooding is possible above this level. One or more flood warnings may be issued', - shortname: 'Possible flood warnings', + description: imtdThresholds.warning.severity_value + ? `${warningType} issued: ${imtdThresholds.warning.ta_name}` + : 'Property flooding is possible above this level', + shortname: imtdThresholds.warning.severity_value ? `${imtdThresholds.warning.ta_name}` : 'Possible flood warnings', value: imtdThresholdWarning - }) + } } + return null +} + +function calculateAlertThreshold (imtdThresholds, stationStageDatum, stationSubtract, postProcess, pc5) { const imtdThresholdAlert = processThreshold(imtdThresholds?.alert, stationStageDatum, stationSubtract, postProcess) + if (imtdThresholdAlert) { - thresholds.push({ + return { id: 'alertThreshold', - description: 'Low lying land flooding is possible above this level. One or more flood alerts may be issued', + description: Number(imtdThresholdAlert) !== Number(pc5) + ? 'Low lying land flooding possible above this level. One or more flood alerts may be issued' + : 'Top of normal range. Low lying land flooding possible above this level. One or more flood alerts may be issued', shortname: 'Possible flood alerts', value: imtdThresholdAlert - }) + } } - return thresholds + + return null } module.exports = processImtdThresholds diff --git a/server/models/views/station.js b/server/models/views/station.js index 904cd05da..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) { @@ -170,7 +171,7 @@ class ViewModel { oneHourAgo.setHours(oneHourAgo.getHours() - 1) - // check if recent value is over one hour old0 + // check if recent value is over one hour old this.dataOverHourOld = new Date(this.recentValue.ts) < oneHourAgo this.recentValue.dateWhen = 'on ' + moment.tz(this.recentValue.ts, tz).format('D/MM/YY') @@ -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,17 +244,17 @@ class ViewModel { shortname: '' }) } + // Add the highest level threshold if available if (this.station.porMaxValue) { thresholds.push({ id: 'highest', value: this.station.porMaxValue, description: this.station.thresholdPorMaxDate - ? 'Water reaches the highest level recorded at this measuring station (recorded on ' + this.station.thresholdPorMaxDate + ')' + ? `Water reaches the highest level recorded at this measuring station (${this.station.thresholdPorMaxDate})` : 'Water reaches the highest level recorded at this measuring station', shortname: 'Highest level on record' }) } - this.imtdThresholds = imtdThresholds?.length > 0 ? filterImtdThresholds(imtdThresholds) : [] @@ -262,9 +263,9 @@ class ViewModel { this.imtdThresholds, this.station.stageDatum, this.station.subtract, - this.station.post_process + this.station.post_process, + this.station.percentile5 ) - thresholds.push(...processedImtdThresholds) if (this.station.percentile5) { @@ -273,10 +274,25 @@ class ViewModel { id: 'pc5', value: this.station.percentile5, description: 'This is the top of the normal range', - shortname: 'Top of 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 @@ -363,7 +379,6 @@ class ViewModel { this.zoom = 14 // Forecast Data Calculations - let forecastData if (isForecast) { this.isFfoi = isForecast @@ -403,6 +418,7 @@ function stationTypeCalculator (stationTypeData) { } return stationType } + function telemetryForecastBuilder (telemetryRawData, forecastRawData, stationType) { const observed = telemetryRawData .filter(telemetry => telemetry._ !== null) // Filter out records where telemetry._ is null @@ -432,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 907d37d64..0162af6a5 100644 --- a/server/routes/station.js +++ b/server/routes/station.js @@ -10,6 +10,7 @@ module.exports = { handler: async (request, h) => { const { id } = request.params let { direction } = request.params + // const thresholdId = request.query.tid? // Convert human readable url to service parameter direction = direction === 'downstream' ? 'd' : 'u' @@ -54,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 }) @@ -67,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 227a6ee4e..54e23d148 100644 --- a/server/src/js/components/chart.js +++ b/server/src/js/components/chart.js @@ -2,13 +2,13 @@ // Chart component import '../utils' +import { area as d3Area, line as d3Line, curveMonotoneX } from 'd3-shape' import { axisBottom, axisLeft } from 'd3-axis' import { scaleLinear, scaleBand, scaleTime } from 'd3-scale' import { timeFormat } from 'd3-time-format' +import { timeHour, timeMinute } from 'd3-time' import { select, selectAll, pointer } from 'd3-selection' import { max, bisector, extent } from 'd3-array' -import { timeHour, timeMinute } from 'd3-time' -import { area as d3Area, line as d3Line, curveMonotoneX } from 'd3-shape' const { forEach, simplify } = window.flood.utils @@ -776,18 +776,25 @@ function LineChart (containerId, stationId, data, options = {}) { .attr('class', 'threshold__line') .attr('aria-hidden', true) .attr('x2', xScale(xExtent[1])).attr('y2', 0) - 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: ') - text.append('tspan').attr('x', 10).attr('y', 22).text(`${threshold.level}m ${threshold.name}`) + // 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) - 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`) - label.attr('transform', `translate(${Math.round(width / 2 - ((textWidth + 20) / 2))}, -46)`) + const textHeight = Math.round(text.node().getBBox().height) + 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})`) + const remove = thresholdContainer.append('a') .attr('role', 'button') .attr('class', 'threshold__remove') @@ -800,6 +807,7 @@ function LineChart (containerId, stationId, data, options = {}) { remove.append('circle').attr('class', 'threshold__remove-button').attr('r', 11) remove.append('line').attr('x1', -3).attr('y1', -3).attr('x2', 3).attr('y2', 3) remove.append('line').attr('y1', -3).attr('x2', -3).attr('x1', 3).attr('y2', 3) + // Set individual elements size and position thresholdContainer.attr('transform', 'translate(0,' + Math.round(yScale(threshold.level)) + ')') }) @@ -931,7 +939,7 @@ function LineChart (containerId, stationId, data, options = {}) { const showTooltip = (tooltipY = 10) => { if (!dataPoint) return // Hide threshold label - thresholdsContainer.select('.threshold--selected .threshold-label').style('visibility', 'hidden') + // thresholdsContainer.select('.threshold--selected .threshold-label').style('visibility', 'hidden') // Set tooltip text const value = dataCache.type === 'river' && (Math.round(dataPoint.value * 100) / 100) <= 0 ? '0' : dataPoint.value.toFixed(2) // *DBL below zero addition tooltipValue.text(`${value}m`) // *DBL below zero addition @@ -1109,7 +1117,8 @@ function LineChart (containerId, stationId, data, options = {}) { // const defaults = { - btnAddThresholdClass: 'defra-button-text-s' + btnAddThresholdClass: 'defra-button-text-s', + btnAddThresholdText: 'Show on chart (Visual only)' } options = Object.assign({}, defaults, options) @@ -1170,16 +1179,30 @@ function LineChart (containerId, stationId, data, options = {}) { const tooltipDescription = tooltipText.append('tspan').attr('class', 'tooltip-text').attr('x', 12).attr('dy', '1.4em') // Add optional 'Add threshold' buttons - document.querySelectorAll('[data-threshold-add]').forEach(container => { - const button = document.createElement('button') - button.className = options.btnAddThresholdClass - button.innerHTML = `Show ${container.getAttribute('data-level')}m threshold on chart (Visual only)` - button.setAttribute('aria-controls', `${containerId}-visualisation`) - button.setAttribute('data-id', container.getAttribute('data-id')) - button.setAttribute('data-threshold-add', '') - button.setAttribute('data-level', container.getAttribute('data-level')) - button.setAttribute('data-name', container.getAttribute('data-name')) - container.parentElement.replaceChild(button, container) + document.querySelectorAll('[data-threshold-add]').forEach((container, i) => { + const tooltip = document.createElement('div') + tooltip.className = 'defra-tooltip defra-tooltip--left' + tooltip.setAttribute('data-tooltip', '') + tooltip.innerHTML = ` + +
{% if model.latestLevels.length > 1 %}These levels{% else %}This level{% endif %} will update automatically
Levels that are very low or below zero are normal for some stations.
{% endif %} - Download data CSV ({% if model.isFfoi %}16{% else %}12{% endif %}KB) + + + + + @@ -263,10 +271,12 @@{% 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 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.')
@@ -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
') }) })