diff --git a/.husky/pre-commit b/.husky/pre-commit index e169f9c..35118b3 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,10 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +# Check if npm is available +if ! command -v npm &> /dev/null; then + echo "Warning: npm not found in PATH. Skipping pre-commit hook." + exit 0 +fi + npm run git:pre-commit-hook diff --git a/src/client/javascripts/line-chart.js b/src/client/javascripts/line-chart.js index 63bbcc7..ba92233 100644 --- a/src/client/javascripts/line-chart.js +++ b/src/client/javascripts/line-chart.js @@ -9,7 +9,7 @@ import { extent, bisector } from 'd3-array' const DISPLAYED_HOUR_ON_X_AXIS = 6 -export function LineChart (containerId, stationId, data, options = {}) { +export function LineChart(containerId, stationId, data, options = {}) { const container = document.getElementById(containerId) if (!container) { @@ -86,6 +86,13 @@ export function LineChart (containerId, stationId, data, options = {}) { .tickFormat('') ) + // Remove grid lines after the latest data point + svg.select('.x.grid').selectAll('.tick').each(function (d) { + if (d > xExtent[1]) { + select(this).remove() + } + }) + // Grid lines don't have labels, so we don't need to remove anything from grid svg.select('.y.grid') @@ -111,7 +118,7 @@ export function LineChart (containerId, stationId, data, options = {}) { .text(timeFormat('%-e %b')(new Date())) // Add height to locator line - svg.select('.locator-line').attr('y1', 0).attr('y2', height) + inner.select('.locator__line').attr('y1', 0).attr('y2', height) // Draw lines and areas if (dataCache.observed.length) { @@ -202,6 +209,7 @@ export function LineChart (containerId, stationId, data, options = {}) { const isForecast = (new Date(dataPoint.dateTime)) > (new Date(dataCache.latestDateTime)) locator.classed('locator--forecast', isForecast) locator.attr('transform', 'translate(' + locatorX + ',' + 0 + ')') + locator.select('.locator__line').attr('y2', height) locator.select('.locator-point').attr('transform', 'translate(' + 0 + ',' + locatorY + ')') } @@ -222,10 +230,12 @@ export function LineChart (containerId, stationId, data, options = {}) { const setScaleX = () => { xExtent = extent(dataCache.observed.concat(dataCache.forecast), (d, i) => { return new Date(d.dateTime) }) - // Don't extend beyond the last recorded time - xScaleInitial = scaleTime().domain(xExtent) + // Add padding to the right side (10% of the time range) + const timeRange = xExtent[1] - xExtent[0] + const paddedMax = new Date(xExtent[1].getTime() + (timeRange * 0.05)) + xScaleInitial = scaleTime().domain([xExtent[0], paddedMax]) xScaleInitial.range([0, width]) - xScale = scaleTime().domain(xExtent) + xScale = scaleTime().domain([xExtent[0], paddedMax]) } const setScaleY = () => { @@ -250,7 +260,10 @@ export function LineChart (containerId, stationId, data, options = {}) { lines = [] if (dataCache.observed && dataCache.observed.length) { - dataCache.observed = simplify(dataCache.observed, dataCache.type === 'tide' ? 10000000 : 1000000) + // Don't simplify river data to preserve 15-minute intervals + if (dataCache.type !== 'river') { + dataCache.observed = simplify(dataCache.observed, dataCache.type === 'tide' ? 10000000 : 1000000) + } const errorFilter = l => !l.err const errorAndNegativeFilter = l => errorFilter(l) const filterNegativeValues = ['groundwater', 'tide', 'sea'].includes(dataCache.type) ? errorFilter : errorAndNegativeFilter @@ -258,7 +271,10 @@ export function LineChart (containerId, stationId, data, options = {}) { dataPoint = lines[lines.length - 1] || null } if (dataCache.forecast && dataCache.forecast.length) { - dataCache.forecast = simplify(dataCache.forecast, dataCache.type === 'tide' ? 10000000 : 1000000) + // Don't simplify river forecast data to preserve 15-minute intervals + if (dataCache.type !== 'river') { + dataCache.forecast = simplify(dataCache.forecast, dataCache.type === 'tide' ? 10000000 : 1000000) + } const latestTime = (new Date(dataCache.observed[0].dateTime).getTime()) const forecastStartTime = (new Date(dataCache.forecast[0].dateTime).getTime()) const latestValue = dataCache.observed[0].value @@ -334,8 +350,8 @@ export function LineChart (containerId, stationId, data, options = {}) { timeLabel.append('tspan').attr('class', 'time-now-text__date').attr('text-anchor', 'middle').attr('x', 0).attr('dy', '15') const locator = inner.append('g').attr('class', 'locator') - locator.append('line').attr('class', 'locator-line') - locator.append('circle').attr('r', 4.5).attr('class', 'locator-point') + locator.append('line').attr('class', 'locator__line').attr('x1', 0).attr('x2', 0).attr('y1', 0).attr('y2', 0) + locator.append('circle').attr('r', 5).attr('class', 'locator-point') const significantContainer = mainGroup.append('g').attr('class', 'significant').attr('role', 'grid').append('g').attr('role', 'row') @@ -381,7 +397,7 @@ export function LineChart (containerId, stationId, data, options = {}) { }) svg.on('click', (e) => { - getDataPointByX(pointer(e)[0]) + getDataPointByX(pointer(e)[0] - margin.left) showTooltip(pointer(e)[1]) }) @@ -396,7 +412,7 @@ export function LineChart (containerId, stationId, data, options = {}) { return } interfaceType = 'mouse' - getDataPointByX(pointer(e)[0]) + getDataPointByX(pointer(e)[0] - margin.left) showTooltip(pointer(e)[1]) }) @@ -408,7 +424,7 @@ export function LineChart (containerId, stationId, data, options = {}) { if (!xScale) return const touchEvent = e.targetTouches[0] const elementOffsetX = svg.node().getBoundingClientRect().left - getDataPointByX(pointer(touchEvent)[0] - elementOffsetX) + getDataPointByX(pointer(touchEvent)[0] - elementOffsetX - margin.left) showTooltip(10) }) diff --git a/src/client/stylesheets/components/_charts.scss b/src/client/stylesheets/components/_charts.scss index 021043a..2e0f720 100644 --- a/src/client/stylesheets/components/_charts.scss +++ b/src/client/stylesheets/components/_charts.scss @@ -1,4 +1,5 @@ // Chart styling to match Check for Flooding service +@use "govuk-frontend" as govuk; .defra-line-chart { margin-bottom: 2rem; @@ -18,7 +19,9 @@ .grid { line { - stroke: #b1b4b6; + fill: none; + color: rgba(govuk.$govuk-text-colour, 0.1); + stroke: currentColor; stroke-width: 1; shape-rendering: crispEdges; } @@ -30,6 +33,7 @@ .axis { line { + color: govuk.$govuk-border-colour; stroke: #0b0c0c; stroke-width: 1; shape-rendering: crispEdges; @@ -42,13 +46,33 @@ text { fill: #0b0c0c; - font-size: 14px; + font-size: 16px; + } + } + + .y.axis { + path { + stroke-width: 0; + } + + .tick line { + stroke-width: 0; + } + } + + .x.axis { + .tick line { + stroke-width: 0; + } + + path { + stroke: govuk.$govuk-border-colour; } } .observed-area { - fill: #1d70b8; - fill-opacity: 0.2; + fill: govuk.govuk-colour("blue"); + fill-opacity: 0.1; } .observed-line { @@ -71,22 +95,22 @@ .time-line { stroke: #0b0c0c; - stroke-width: 2; - stroke-dasharray: 3, 3; + stroke-width: 1; + shape-rendering: crispEdges; } .time-now-text { fill: #0b0c0c; - font-size: 12px; + font-size: 16px; text-anchor: middle; font-weight: bold; } .locator { &__line { - stroke: #0b0c0c; - stroke-width: 1; - opacity: 0; + stroke: #b1b4b6; + stroke-width: 0.25; + opacity: 1; &--visible { opacity: 1; @@ -113,12 +137,6 @@ opacity: 1; } } - - &--forecast { - .locator-point { - stroke: #f47738; - } - } } .tooltip { @@ -130,16 +148,18 @@ } &-bg { - fill: #0b0c0c; + fill: #fff; + stroke: #0b0c0c; + stroke-width: 0.5; } &-text { - fill: #fff; - font-size: 14px; + fill: #0b0c0c; + font-size: 16px; &__strong { font-weight: 700; - font-size: 16px; + font-size: 18px; } &__small { @@ -192,7 +212,7 @@ } &--forecast circle { - stroke: #f47738; + stroke: #1d70b8; } text { @@ -241,4 +261,4 @@ &__text { font-size: 16px; } -} +} \ No newline at end of file diff --git a/src/common/helpers/serve-static-files.js b/src/common/helpers/serve-static-files.js index 98e7bd5..2c86bcd 100644 --- a/src/common/helpers/serve-static-files.js +++ b/src/common/helpers/serve-static-files.js @@ -1,4 +1,5 @@ import http2 from 'node:http2' +import path from 'node:path' import { config } from '../../config/config.js' const { constants: httpConstants } = http2 @@ -6,7 +7,7 @@ const { constants: httpConstants } = http2 export const serveStaticFiles = { plugin: { name: 'staticFiles', - register (server) { + register(server) { server.route([ { options: { @@ -18,7 +19,7 @@ export const serveStaticFiles = { }, method: 'GET', path: '/favicon.ico', - handler (_request, h) { + handler(_request, h) { return h.response().code(httpConstants.HTTP_STATUS_NO_CONTENT).type('image/x-icon') } }, @@ -34,7 +35,7 @@ export const serveStaticFiles = { path: `${config.get('assetPath')}/{param*}`, handler: { directory: { - path: '.', + path: path.join(config.get('root'), '.public'), redirectToSlash: true } } diff --git a/src/lib/flood-service.js b/src/lib/flood-service.js index 9895028..8cf01f3 100644 --- a/src/lib/flood-service.js +++ b/src/lib/flood-service.js @@ -7,7 +7,7 @@ const API_BASE_URL = config.get('api.floodMonitoring.baseUrl') * Fetch via proxy using Node.js native fetch * To use the fetch dispatcher option on Node.js native fetch, Node.js v18.2.0 or greater is required */ -export function proxyFetch (url, options = {}) { +export function proxyFetch(url, options = {}) { const proxyUrlConfig = config.get('httpProxy') // bound to HTTP_PROXY if (!proxyUrlConfig) { @@ -34,13 +34,13 @@ export function proxyFetch (url, options = {}) { /** * Fetch station details by RLOI ID (Check for Flooding ID) */ -export async function getStation (stationId) { +export async function getStation(stationId) { const url = `${API_BASE_URL}/id/stations?RLOIid=${stationId}` try { console.log(`Fetching station from: ${url}`) const response = await proxyFetch(url) console.log(`Station API response status: ${response.status} ${response.statusText}`) - + if (!response.ok) { const errorText = await response.text().catch(() => 'Unable to read error response') throw new Error(`Failed to fetch station: ${response.status} ${response.statusText} - ${errorText}`) @@ -70,7 +70,7 @@ export async function getStation (stationId) { /** * Fetch station readings/measurements */ -export async function getStationReadings (stationId, since = null) { +export async function getStationReadings(stationId, since = null) { const stationUrl = `${API_BASE_URL}/id/stations?RLOIid=${stationId}` try { // First get the station to find its measures @@ -130,10 +130,15 @@ export async function getStationReadings (stationId, since = null) { /** * Format station data for the view */ -export function formatStationData (station, readings) { +export function formatStationData(station, readings) { if (!station) return null - const latestReading = readings.length > 0 ? readings[readings.length - 1] : null + // Get the most recent reading by date (readings might not be sorted correctly) + const latestReading = readings.length > 0 + ? readings.reduce((latest, current) => + new Date(current.dateTime) > new Date(latest.dateTime) ? current : latest + ) + : null const latestValue = latestReading?.value || 0 // Calculate trend (simplified - compare to reading from 1 hour ago) @@ -185,7 +190,7 @@ export function formatStationData (station, readings) { /** * Format readings for chart */ -export function formatTelemetryData (readings) { +export function formatTelemetryData(readings) { // Filter to last 5 days only const fiveDaysAgo = new Date() fiveDaysAgo.setDate(fiveDaysAgo.getDate() - 5) @@ -214,7 +219,7 @@ export function formatTelemetryData (readings) { /** * Search for stations */ -export async function searchStations (query = {}) { +export async function searchStations(query = {}) { try { const params = new URLSearchParams() if (query.label) params.append('label', query.label) diff --git a/src/routes/index.js b/src/routes/index.js index e59b1e7..3c7780f 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -10,12 +10,13 @@ export const index = [ method: 'POST', path: '/station', handler: async function (request, h) { - const { dataType, stationType, stationId } = request.payload + const { dataType, stationType, stationId, chartStyle } = request.payload // Build query params const params = new URLSearchParams({ dataType: dataType || 'existing', - stationType: stationType || 'S' + stationType: stationType || 'S', + chartStyle: chartStyle || 'styleA' }) // Add stationId if provided diff --git a/src/routes/station.js b/src/routes/station.js index 364d0ef..20d2ae8 100644 --- a/src/routes/station.js +++ b/src/routes/station.js @@ -4,11 +4,11 @@ export const station = { method: 'GET', path: '/station', handler: async function (request, h) { - const { dataType, stationType, stationId = '8085' } = request.query + const { dataType, stationType, stationId = '8085', chartStyle = 'styleA' } = request.query try { - request.logger.info(`Fetching station data for ID: ${stationId}`) - + request.logger.info(`Fetching station data for ID: ${stationId}, style: ${chartStyle}`) + // Fetch real data from Environment Agency API const [stationData, readings] = await Promise.all([ getStation(stationId), @@ -31,7 +31,8 @@ export const station = { station, telemetry, dataType: dataType || 'existing', - stationType: stationType || station.type + stationType: stationType || station.type, + chartStyle }) } catch (error) { request.logger.error('Error loading station data:', error) diff --git a/src/views/error.njk b/src/views/error.njk index c2b3b58..9e8e05f 100644 --- a/src/views/error.njk +++ b/src/views/error.njk @@ -58,5 +58,5 @@ {% endblock %} {% block bodyEnd %} - + {% endblock %} diff --git a/src/views/index.njk b/src/views/index.njk index 35f9e95..5efebd1 100644 --- a/src/views/index.njk +++ b/src/views/index.njk @@ -3,6 +3,7 @@ {% from "govuk/components/radios/macro.njk" import govukRadios %} {% from "govuk/components/input/macro.njk" import govukInput %} {% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/phase-banner/macro.njk" import govukPhaseBanner %} {% set mainClasses = "app-main-wrapper" %} @@ -26,6 +27,12 @@ {% endblock %} {% block beforeContent %} + {{ govukPhaseBanner({ + tag: { + text: "Prototype" + }, + html: 'This is a prototype for testing ideas. It is not a real service.' + }) }} {% endblock %} {% block content %} @@ -97,6 +104,43 @@ ] }) }} + {{ govukRadios({ + name: "chartStyle", + fieldset: { + legend: { + text: "Chart style", + classes: "govuk-fieldset__legend--m" + } + }, + hint: { + text: "Select which chart style variant to view" + }, + items: [ + { + value: "styleA", + text: "Style A - Current design (5 days of data)", + hint: { + text: "The existing implementation with 5 days of recent data" + }, + checked: true + }, + { + value: "styleB", + text: "Style B - Date range selector (up to 5 years)", + hint: { + text: "Includes a date range selector at the top to view historical data" + } + }, + { + value: "styleC", + text: "Style C - Interactive timeline (up to 5 years)", + hint: { + text: "Drag the chart and use scroll wheel to navigate through historical data" + } + } + ] + }) }} + {{ govukButton({ text: "Continue to station" }) }} diff --git a/src/views/layouts.njk b/src/views/layouts.njk index 8a353cb..be3770f 100644 --- a/src/views/layouts.njk +++ b/src/views/layouts.njk @@ -1,4 +1,5 @@ {% extends "govuk/template.njk" %} +{% from "govuk/components/phase-banner/macro.njk" import govukPhaseBanner %} {% set mainClasses = "app-main-wrapper" %} @@ -22,6 +23,12 @@ {% endblock %} {% block beforeContent %} + {{ govukPhaseBanner({ + tag: { + text: "Prototype" + }, + html: 'This is a prototype for testing ideas. It is not a real service.' + }) }} {% endblock %} {% block content %}{% endblock %} diff --git a/src/views/station.njk b/src/views/station.njk index 15df8c8..f24cae3 100644 --- a/src/views/station.njk +++ b/src/views/station.njk @@ -1,6 +1,7 @@ {% extends "govuk/template.njk" %} {% from "govuk/components/back-link/macro.njk" import govukBackLink %} +{% from "govuk/components/phase-banner/macro.njk" import govukPhaseBanner %} {% set mainClasses = "app-main-wrapper" %} @@ -29,6 +30,12 @@ {% endblock %} {% block beforeContent %} + {{ govukPhaseBanner({ + tag: { + text: "Prototype" + }, + html: 'This is a prototype for testing ideas. It is not a real service.' + }) }} {{ govukBackLink({ text: "Back", href: "/" @@ -119,10 +126,30 @@ {# Chart Section #}