diff --git a/Gemfile b/Gemfile
index afbf61d3..dd0c1c45 100644
--- a/Gemfile
+++ b/Gemfile
@@ -4,6 +4,7 @@ source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby "3.3.6"
+gem "active_link_to", "~> 1.0" # Active links with CSS classes
gem "bootsnap", ">= 1.1.0", require: false
gem "high_voltage"
gem "jbuilder", "~> 2.11"
diff --git a/Gemfile.lock b/Gemfile.lock
index cca5ab7f..01e2c0e7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -45,6 +45,9 @@ GEM
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
+ active_link_to (1.0.5)
+ actionpack
+ addressable
activejob (7.2.2)
activesupport (= 7.2.2)
globalid (>= 0.3.6)
@@ -452,6 +455,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
+ active_link_to (~> 1.0)
better_errors
binding_of_caller
bootsnap (>= 1.1.0)
diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
index 7f38e824..3db39c93 100644
--- a/app/assets/config/manifest.js
+++ b/app/assets/config/manifest.js
@@ -1,7 +1,5 @@
//= link_tree ../images
-// include our initial GOV.UK based stylesheets for now whilst
-// migrating to ESBuild bundled assets...
-//= link_directory ../stylesheets .css
+//= link_directory ../builds .css
//= link_tree ../builds
diff --git a/app/assets/stylesheets/application.tailwind.css.scss b/app/assets/stylesheets/application.tailwind.css.scss
index fefb1167..a7a77f07 100644
--- a/app/assets/stylesheets/application.tailwind.css.scss
+++ b/app/assets/stylesheets/application.tailwind.css.scss
@@ -5,6 +5,8 @@
@import "leaflet.scss"; /* stylelint-disable-line scss/load-partial-extension */
@import "daqi-levels.scss"; /* stylelint-disable-line scss/load-partial-extension */
@import "share.scss"; /* stylelint-disable-line scss/load-partial-extension */
+@import "header.scss"; /* stylelint-disable-line scss/load-partial-extension */
+@import "footer.scss"; /* stylelint-disable-line scss/load-partial-extension */
@tailwind base;
@tailwind components;
@@ -15,62 +17,28 @@
--main-colour-light: #dae7f7;
}
-header.site-header {
- background-color: var(--main-colour);
-
- @apply flex flex-row p-4;
-
- .site-title {
- @apply text-white flex-auto;
-
- .site-name {
- @apply text-2xl font-bold block;
- }
-
- .site-strapline {
- @apply text-sm block;
- }
- }
-
- nav {
- @apply text-white flex-auto text-right;
-
- button {
- @apply text-5xl;
- }
-
- ul {
- @apply text-sm;
- }
- }
-}
-
-main {
- padding: 1rem;
-}
-
.tabs {
- @apply flex flex-row items-stretch border-b-[2px] border-black;
+ @apply flex flex-row items-stretch border-b-[1px] border-zinc-500 mx-4;
border-radius: 10px 10px 0 0;
gap: 0.5rem;
}
.tab {
- @apply flex-1 px-1 py-4 text-xs text-center font-bold border-[2px];
+ @apply flex-1 px-1 py-4 text-xs text-center font-bold border-[1px] cursor-pointer;
border-radius: 10px 10px 0 0;
&.active {
- @apply border-black border-solid border-b-0 -mb-[2px] bg-white;
+ @apply border-zinc-500 border-solid border-b-0 -mb-[1px];
}
&.inactive {
- @apply border-gray-400 border-dashed border-b-0 text-gray-400;
+ @apply border-gray-400 border-dashed border-b-0 text-gray-400 bg-white;
}
.daqi-indicator {
- @apply size-9 rounded-full mx-auto my-1 flex justify-center items-center;
+ @apply size-7 rounded-full mx-auto my-1 flex justify-center items-center p-[3px];
}
.daqi-label {
@@ -83,9 +51,8 @@ main {
}
.tab-contents {
- padding: 0.5rem;
- border: 2px solid black;
- border-top: 0;
+ @apply p-4 mx-4 border-[1px] border-t-0 border-zinc-500;
+
border-radius: 0 0 10px 10px;
}
@@ -117,23 +84,23 @@ main {
}
}
+.forecast-timestamp {
+ @apply text-xs text-gray-500 text-center mt-2 block italic;
+}
+
.sharing {
button {
- @apply flex mx-auto mt-6 px-8 py-2 rounded-md text-white justify-center;
+ @apply flex mx-auto mt-2 px-8 py-2 rounded-md text-white justify-center;
background-color: var(--main-colour);
}
}
-.forecast-timestamp {
- @apply text-sm text-center text-gray-500 mt-2 block;
-}
-
.learning,
.subscribe {
background-color: var(--main-colour-light);
- @apply p-8 mt-2 text-center;
+ @apply p-8 mt-4 text-center;
header {
@apply font-bold;
@@ -146,15 +113,9 @@ main {
}
}
-select {
- @apply inline-flex flex-row w-full gap-x-1.5 rounded-md bg-white px-3 py-2 text-base font-semibold text-gray-500 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50;
-}
-
-.home-page-link {
- color: revert;
-}
-
.text-block {
+ @apply p-4;
+
h1 {
@apply text-2xl font-bold mt-5 mb-3;
}
@@ -198,4 +159,49 @@ select {
@apply px-2 py-1 border border-gray-400;
}
}
+
+ details {
+ summary {
+ cursor: pointer;
+
+ &::marker {
+ content: none; /* Hide the default marker */
+ }
+
+ h3 {
+ margin: 0;
+
+ &::after {
+ content: "\002B"; /* Unicode character for plus sign */
+ display: flex;
+ width: 1.5rem;
+ aspect-ratio: 1;
+ float: right;
+ align-items: center;
+ justify-content: center;
+ padding-bottom: 3px;
+ font-size: 1.3rem;
+ line-height: 0;
+ font-weight: normal;
+ color: black;
+ border: 1px solid black;
+ border-radius: 100%;
+ }
+ }
+ }
+
+ &[open] summary h3 {
+ margin-bottom: 1rem;
+
+ &::after {
+ content: "\2212"; /* Unicode character for minus sign */
+ }
+ }
+ }
+}
+
+.how-forecasts-are-made {
+ @apply py-8 px-4;
+
+ background-color: #eee;
}
diff --git a/app/assets/stylesheets/footer.scss b/app/assets/stylesheets/footer.scss
new file mode 100644
index 00000000..846e3a20
--- /dev/null
+++ b/app/assets/stylesheets/footer.scss
@@ -0,0 +1,33 @@
+.site-footer {
+ @apply text-white py-8 px-6;
+
+ background-color: var(--main-colour);
+
+ nav {
+ li {
+ @apply py-2;
+
+ a.active {
+ @apply font-bold underline;
+ }
+ }
+ }
+
+ .terms-and-conditions {
+ @apply mt-6;
+
+ font-size: 0; /* Remove whitespace between inline-block elements */
+
+ li {
+ @apply text-xs inline-block border-r-white border-r px-2;
+
+ &:first-child {
+ @apply pl-0;
+ }
+
+ &:last-child {
+ @apply border-0;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/header.scss b/app/assets/stylesheets/header.scss
new file mode 100644
index 00000000..cf873a82
--- /dev/null
+++ b/app/assets/stylesheets/header.scss
@@ -0,0 +1,55 @@
+.site-header {
+ background-color: var(--main-colour);
+
+ @apply flex flex-row flex-wrap p-4;
+
+ .site-title {
+ @apply text-white text-center;
+
+ .site-name {
+ @apply text-4xl font-bold block;
+ }
+
+ .site-strapline {
+ @apply text-xs block;
+ }
+ }
+
+ button {
+ @apply text-5xl flex-auto text-white text-right;
+
+ line-height: 0;
+
+ &::before {
+ content: "☰";
+ font-size: 4rem;
+ bottom: 0.25rem;
+ position: relative;
+ }
+
+ &.menu-open {
+ &::before {
+ content: "✕";
+ font-size: 3.25rem;
+ bottom: 0;
+ right: 8px;
+ }
+ }
+ }
+
+ nav {
+ @apply text-white w-full;
+
+ ul {
+ @apply text-xl my-4;
+
+ li {
+ @apply pt-4;
+
+ a.active {
+ @apply font-bold underline;
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/leaflet.scss b/app/assets/stylesheets/leaflet.scss
index fa8b5815..c7da1b25 100644
--- a/app/assets/stylesheets/leaflet.scss
+++ b/app/assets/stylesheets/leaflet.scss
@@ -20,3 +20,41 @@
/* From node_modules/leaflet.fullscreen/icon-fullscreen.svg */
background-image: url('data:image/svg+xml;charset=UTF-8,');
}
+
+.leaflet-pane {
+ .maplibregl-ctrl-attrib {
+ display: none; /* Hide duplicate attributions */
+ }
+}
+
+.leaflet-container {
+ a {
+ left: 2px !important;
+
+ img {
+ width: 50px; /* Make the Maptiler logo smaller to fit on mobile */
+ }
+ }
+}
+
+.leaflet-control:not(.leaflet-control-attribution) {
+ border: 2px solid rgb(0 0 0 / 20%);
+ box-shadow: none;
+ border-radius: 4px;
+}
+
+.leaflet-top:has(.leaflet-ctrl-geocoder) {
+ max-width: calc(100% - 60px);
+
+ .leaflet-ctrl-geocoder {
+ max-width: 100%;
+
+ form {
+ max-width: 100%;
+
+ input {
+ height: 30px;
+ }
+ }
+ }
+}
diff --git a/app/components/day_tab_component.html.erb b/app/components/day_tab_component.html.erb
index b9bf28e2..d05d7878 100644
--- a/app/components/day_tab_component.html.erb
+++ b/app/components/day_tab_component.html.erb
@@ -1,4 +1,4 @@
-<%= tag.div class: "tab #{@day} #{@active ? 'active' : 'inactive'}",
+<%= tag.div class: "tab #{@day} #{@active ? 'active' : 'inactive'} #{@forecast.air_pollution.label == 'LOW' ? 'bg-white' : daqi_indicator_colour_class}",
data: {
date: @forecast.date.to_s,
day: @day,
diff --git a/app/components/day_tab_component.rb b/app/components/day_tab_component.rb
index 24b84f4f..05f7d13f 100644
--- a/app/components/day_tab_component.rb
+++ b/app/components/day_tab_component.rb
@@ -10,6 +10,6 @@ def daqi_indicator_colour_class
end
def icon_stroke_colour_class
- (@forecast.air_pollution.label == "HIGH") ? "stroke-white" : "stroke-black"
+ ["High", "Very high"].include?(@forecast.air_pollution.daqi_label) ? "stroke-white" : "stroke-black"
end
end
diff --git a/app/controllers/forecasts_controller.rb b/app/controllers/forecasts_controller.rb
index 32f808e6..57b90e64 100644
--- a/app/controllers/forecasts_controller.rb
+++ b/app/controllers/forecasts_controller.rb
@@ -16,8 +16,10 @@ def show
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
- turbo_stream.replace("forecasts-frame-top", partial: "forecasts/top"),
- turbo_stream.replace("forecasts-frame-bottom", partial: "forecasts/bottom")
+ turbo_stream.replace("forecast-tabs-frame", partial: "forecasts/forecast_tabs"),
+ turbo_stream.replace("alert-guidance-frame", partial: "forecasts/alert_guidance"),
+ turbo_stream.replace("predictions-frame", partial: "forecasts/predictions"),
+ turbo_stream.replace("sharing-frame", partial: "forecasts/sharing")
]
end
format.html
diff --git a/app/javascript/controllers/map_controller.js b/app/javascript/controllers/map_controller.js
index ac362643..05461f9d 100644
--- a/app/javascript/controllers/map_controller.js
+++ b/app/javascript/controllers/map_controller.js
@@ -59,7 +59,7 @@ export default class MapController extends Controller {
zoomControl: false,
fullscreenControl: true,
fullscreenControlOptions: {
- position: "topright",
+ position: "bottomright",
},
});
@@ -165,7 +165,7 @@ export default class MapController extends Controller {
addGeolocationControl() {
new LocateControl({
- position: "bottomright",
+ position: "topright",
}).addTo(this.map);
}
diff --git a/app/javascript/controllers/navigation_controller.js b/app/javascript/controllers/navigation_controller.js
index 42b5b541..161757a4 100644
--- a/app/javascript/controllers/navigation_controller.js
+++ b/app/javascript/controllers/navigation_controller.js
@@ -1,9 +1,10 @@
import { Controller } from "@hotwired/stimulus";
export default class NavigationController extends Controller {
- static targets = ["menuList"];
+ static targets = ["menuButton", "menuList"];
toggleMenu() {
this.menuListTarget.classList.toggle("hidden");
+ this.menuButtonTarget.classList.toggle("menu-open");
}
}
diff --git a/app/models/concerns/daqi_properties.rb b/app/models/concerns/daqi_properties.rb
index 0134f5de..33b75865 100644
--- a/app/models/concerns/daqi_properties.rb
+++ b/app/models/concerns/daqi_properties.rb
@@ -12,6 +12,8 @@ def daqi_label
def daqi_level
case @value
+ when -999
+ :low
when 1..3
:low
when 4..6
diff --git a/app/models/pollen_prediction.rb b/app/models/pollen_prediction.rb
index 48358c22..6b92c1de 100644
--- a/app/models/pollen_prediction.rb
+++ b/app/models/pollen_prediction.rb
@@ -15,10 +15,6 @@ def guidance
I18n.t("prediction.guidance.pollen.#{daqi_level}")
end
- def valid?
- value != -999
- end
-
# :nocov:
def inspect
"#<#{self.class.name} @value=#{value}>"
diff --git a/app/models/temperature_prediction.rb b/app/models/temperature_prediction.rb
index cc6a2ac9..fb589cf3 100644
--- a/app/models/temperature_prediction.rb
+++ b/app/models/temperature_prediction.rb
@@ -1,18 +1,24 @@
class TemperaturePrediction
- attr_reader :min, :max
+ attr_reader :min_c, :max_c, :min_f, :max_f
def initialize(min:, max:)
- @min = min
- @max = max
+ @min_c = min
+ @max_c = max
+ @min_f = farenheit(min)
+ @max_f = farenheit(max)
end
def name
"Temperature"
end
+ def farenheit(celsius)
+ (celsius * 9 / 5) + 32
+ end
+
# :nocov:
def inspect
- "#<#{self.class.name} @min=#{min} @max=#{max}>"
+ "#<#{self.class.name} @min=#{min_c} @max=#{max_c}>"
end
# :nocov:
end
diff --git a/app/views/forecasts/_alert_guidance.html.erb b/app/views/forecasts/_alert_guidance.html.erb
index 1a27995a..b97c132e 100644
--- a/app/views/forecasts/_alert_guidance.html.erb
+++ b/app/views/forecasts/_alert_guidance.html.erb
@@ -1,3 +1,4 @@
+
@@ -8,3 +9,4 @@
The air quality forecasts are produced using <%= link_to("CERC's air pollution forecasting system", 'https://www.cerc.co.uk/forecast', target: '_blank') %>. Information from weather forecasts, forecasts of pollution across Europe from the <%= link_to('CAMS', 'https://atmosphere.copernicus.eu/', target: '_blank') %> Regional Ensemble and very detailed data on about 30,000 pollution sources across London are fed into the world-leading <%= link_to('ADMS-Urban', 'https://www.cerc.co.uk/environmental-software/ADMS-Urban-model.html', target: '_blank') %> air quality model to produce forecasts of air quality at a high degree of spatial resolution (10m). These forecasts are issued twice a day at about 7am and 7pm. The forecasts are updated throughout the day using real-time monitoring data from LondonAir. We compare the forecasts with observed pollution levels to assess the accuracy of the forecasts. These performance statistics are available <%= link_to('here', './pdfs/airTEXT 2023 performance.pdf', target: '_blank') %>.
+The hourly concentrations of four pollutants are calculated: nitrogen dioxide (NO2), particulates (PM10 and PM2.5) and ozone (O3). From the hourly concentrations the daily air quality index (<%= link_to('DAQI', 'https://uk-air.defra.gov.uk/air-pollution/daqi', target: '_blank') %>) of each pollutant is derived. The overall air quality index is determined by the highest index for any of these pollutants.
+airTEXT issues an alert for a local authority or region if at least 10% of the geographical area is predicted to reach MODERATE or above.
+Forecast values of UV and temperature are supplied by <%= link_to('DTN', 'https://www.dtn.com/weather/', target: '_blank') %>. The pollen forecast is supplied by the <%= link_to('Met Office', 'https://www.metoffice.gov.uk/weather/warnings-and-advice/seasonal-advice/pollen-forecast', target: '_blank') %>.
+