Commuting to London
Global Cities teamIntroduction
After Covid, the world of London office work split into those workplaces that have fully embraced flexible working, and those that have not. In the latter camp are many of the major Investment Banks, Magic Circle law firms, and consultancies. They require their staff to be in the office most of the time (typically 4+ days a week). Salaries are typically higher in these professions – compensating these employees well for the inconvenience.
In the former camp are most tech firms, data providers, smaller financial service companies, and the public sector. They expect their employees to come into the office on several days each week (typically 2 – 3). Predictably, salaries are set a little lower – with employers able to offer workplace flexibility as a ‘benefit-in-kind’.
This bifurcation in attitudes to work from home has resulted in the emergence of three distinct commuting ‘strategies’:
Daily Commuters: Expected to come in on most days, these commuters typically walk or cycle to the office, use TfL public transport if they live within Zone 1 or 2, or are using the National Rail system for shorter daily journeys.
Sporadic Commuters: These commuters come in 2 to 3 days a week. They might make use of daily or open return rail tickets, but do not typically overnight in London. Though they might live in London by choice, many have moved to the suburbs.
Multi-Day Commuters: These commuters make use of open returns or single tickets to stay several days in London at a stretch, book-ending the weekend with working from home on a Monday and Friday.
For our 10-year anniversary, we decided to apply some science to these commuting patterns to identify some of the best commuting stations to live near for each strategy.
For a full explanation of our methodology, see Methodology & Glossary
Visualising the Results
Use the map below to see the Threshold Salary required to make the purchase of the average property in the area (we use Local Authority average prices from the ONS). We assume buyers are set on buying a certain property type, e.g. a flat, or detached house.
Assumptions
dropdownWidth = 100;
labelWidth = 200;
// Analysis Type
// viewof analysis = Inputs.radio( analyses,
// { value: 'Best Housing Affordable',
// multiple: false
// });
analysis = 'Threshold Salary'
// Commuting Mode
viewof commute_mode = Inputs.select( commute_modes,
{ value: 'Daily',
format: x => x.label,
multiple: false
});
// Desired Housing
viewof housing_type = Inputs.select( housing_types,
{ value: 'detached',
format: x => x.label,
multiple: false,
})
budget = 100000;
// Number of Children
viewof children = Inputs.select( children_options,
{ format: x => x.label,
value: 0,
multiple: false
});
// Number of Earners in Household
viewof hhld = Inputs.select( hhld_options,
{ format: x => x.label,
value: 2,
multiple: false
});
inputs_left = [
{ label: "Commuting Strategy:", input: viewof commute_mode },
{ label: "Target Property Type:", input: viewof housing_type},
{ label: "Number of Earners:", input: viewof hhld},
{ label: "Number of Children of Nursery Age:", input: viewof children}
];
// Create the table with invisible borders and explicit widths
html`<table style="border-collapse: collapse; border: none;">
${inputs_left.map(({ label, input }) => html`
<tr style="border: none; background-color: #dbebff;">
<td style="width: ${labelWidth}px; white-space: nowrap; padding-right: 20px; text-align: left; border: none;">${label}</td>
<td style="width: ${dropdownWidth}px; text-align: left; border: none; padding-top: 8px; padding-bottom: 8px;">${input}</td>
</tr>
`)}
</table>
`;
Map Control
all_optional_layers = { return {
}};
// 'Railway Lines',
layerOrder = ['LSOA Layer', 'Railways', 'Stations', "Highlighted Stations"]
optional_layer_options = ['Stations', 'Railways']; // 'Highlighted Stations',
optional_layers_selected_ = optional_layer_options;
async function returnStationSearchOptions() {
let stations = await d3.json("https://eu-assets.contentstack.com/v3/assets/bltc68ab8a4d37a1b99/blt4caedcaddd38eaf5/66ed6da580ecd84eda58e515/stations.geojson");
let simplifiedStations = stations.features.map(feature => {
return {
label: feature.properties.stationname,
value: [feature.geometry.coordinates[1],
feature.geometry.coordinates[0]],
crs_code: feature.properties.crs_code
};
});
return simplifiedStations
}
function zoomToStation(station_zoom, lsoa_map) {
console.log("Zooming Map to a Station...")
if (station_zoom.value !== 'Search...') {
const coordinates = station_zoom.value;
// Check if coordinates exist
if (coordinates && Array.isArray(coordinates) && coordinates.length === 2) {
console.log("Is Valid coordinates...")
lsoa_map.setView(coordinates, 13);
} else {
console.error("Invalid coordinates in station_zoom.value");
}
}
console.log("Zooming Complete")
return 1
}
dummy_var_zoom = zoomToStation(station_zoom, lsoa_map);
station_options_ = returnStationSearchOptions();
station_options = [{'value':null, 'label':'Search...'}].concat(station_options_);
viewof station_zoom = Inputs.select(station_options,
{value: null,
multiple: false,
searchable: true,
format: x => x.label
}
);
// viewof highlight_stations_obj = Inputs.select(station_options,
// {value: [],
// multiple: true,
// searchable: true,
// format: x => x.label
// }
// );
// Map Reset Button
button = html`<button id="reset_map">Reset Map</button>`;
dummy_variable_6 = document.getElementById('reset_map').addEventListener('click', function() {
lsoa_map.fitBounds(current_bounds);
});
inputs_right = [
// { label: 'Overlay:', input: viewof optional_layers_selected_},
{ label: 'Zoom Map to Station:', input: viewof station_zoom},
{ label: '', input: button}
// { label: 'Highlight Stations:', input: viewof highlight_stations_obj}
];
// highlight_stations = highlight_stations_obj.map(station => station.crs_code);
// Used for specific Hugo Map:
// ["LAI", "BIC", "HWM", "BIW", "LUT", "LBZ", "MKC", "SLO", "BSK", "TBD", "AFS", "LGF", "GRY", "SOO", "HAT"];
highlight_stations = [];
// Create the table with invisible borders and explicit widths
html`<table style="border-collapse: collapse; border: none;">
${inputs_right.map(({ label, input }) => html`
<tr style="border: none; background-color: #dbebff;">
<td style="width: ${labelWidth}px; white-space: nowrap; padding-right: 20px; text-align: left; border: none;">${label}</td>
<td style="width: ${dropdownWidth}px; text-align: left; border: none; padding-top: 8px; padding-bottom: 8px;">${input}</td>
</tr>
`)}
</table>
`;
html`
<style>
body {
font-family: Schroders Circular !important;
font-size: 16px !important;
}
h2 {
font-size: 28px !important;
}
h3 {
font-size: 24px !important;
}
range:disabled {
color: darkgrey; /* Set the text color to light grey */
background-color: darkgrey; /* Set the background color to dark grey */
cursor: not-allowed; /* Make the cursor appear as 'disabled' */
opacity: 0.6; /* Slightly reduce the opacity for a more 'disabled' feel */
}
div.observablehq th {
background-color: rgba(0,42,94,1) !important;
color: rgba(255,255,255,1) !important;
}
.observablehq table {
font-size: 0.9em !important;
}
table {
width: 100% !important;
}
th {
white-space: unset !important;
}
td {
white-space: unset !important;
}
tr:nth-child(even) {
background-color: #e0e0e0;
}
.tick {
font-size: 16px;
}
</style>
`
container = {
let x = d3.create("div")
x.attr("style", `width:100%; margin-right:50px; aspect-ratio: 3 / 2; max-height: 900px;`);
return x.node();
}
lsoa_map = {
let map = L.map(container, {
zoomSnap: 0, // Enable fractional zooming
zoomDelta: 0.25 // Allow zoom increments of 0.25
});
map.createPane('labels');
map.getPane('labels').style.zIndex = 650; // Higher zIndex to make sure labels are on top
map.getPane('tooltipPane').style.zIndex = 700; // Tooltip on top of everything
// CartoDB Positron No-Labels (basemap)
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 20
}).addTo(map);
// CartoDB Positron Labels-Only (labels overlay)
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}.png', {
attribution: '© <a href="https://carto.com/attributions">CARTO</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
subdomains: 'abcd',
maxZoom: 20,
pane: 'labels' // Add the labels layer to the custom pane
}).addTo(map);
return map;
};
opac = 0.8;
railcolour = "#00796D"
styles = ({
railways: {
weight: 1,
color: railcolour,
opacity: 1
},
stations: {
radius: 3,
weight: 1,
fillColor: railcolour,
fillOpacity: opac,
color: "rgb(255,255,255)",
opacity: opac
},
highlighted_stations: {
radius: 4,
weight: 1,
fillColor: 'rgb(25,25,25)',
fillOpacity: opac,
color: "rgb(255,255,255)",
opacity: opac
},
choropleth: {
weight: 1,
color: "red",
opactiy: opac,
fillColor:"red",
fillOpacity: opac,
},
highlight: {
weight: 1.5,
fillColor: "#35e6ab",
fillOpacity: opac,
color: "#5e5e5e",
opacity: opac
},
select: {
weight: 2,
fillColor: "#e69035",
fillOpacity: opac,
color: "#5e5e5e",
opacity: opac
}
});
current_bounds = bound_options[commute_mode.value];
function formatPrice(value) {
return value ? `£${Math.round(value).toLocaleString()}` : 'N/A';
}
function updateMap(_map_, layerOrder, optional_layers_selected, highlight_stations) {
console.log("Update Map Triggered")
for (const [layerName, layer] of Object.entries(all_optional_layers)) {
console.log("Removing: ",layerName)
removeExistingLayer(_map_, layerName)
}
for (const layerName of layerOrder) {
if (optional_layers_selected.includes(layerName)) {
const layer = all_optional_layers[layerName];
_map_.addLayer(layer);
}
}
if (_map_.legendControl) {
_map_.removeControl(_map_.legendControl);
}
let legend = makeLegend(analysis, optional_layers_selected);
if (legend) {
legend.addTo(_map_);
_map_.legendControl = legend;
}
console.log("---------------")
console.log("Update Complete")
console.log("---------------")
return 1;
}
function countLayers(map) {
let layerCount = 0;
map.eachLayer(function(layer) {
layerCount++;
});
return layerCount;
}
function removeExistingLayer(map, layer_name) {
map.eachLayer(function(layer) {
if (layer.options && layer.options.name === layer_name) {
map.removeLayer(layer);
}
});
};
mutable cached_lsoa_data = null;
async function loadAndFilterLSOA(commute_mode, analysis, budget, housing_type, children, selected_lsoa) {
// Color Variable - i.e. which column to plot the values of as the choropleth map
let colorvar = housing_type.value + '_gross_pp_req' + '_' + children.value + '_kid_' + hhld.value + '_hhld';
// On Mouseover, colour this feature as highlighted
const highlightFeature = (e) => {
e.target.setStyle(styles.highlight);
e.target.openTooltip();
};
// On Mouseover, colour this feature as basic style - UNLESS it is already the selected feature
const resetHighlight = (e) => {
let q = (e.target.feature.properties["lsoa11cd"] == selected_lsoa) ? styles.select : styleFunction(e.target.feature, colorvar);
e.target.setStyle(q);
e.target.closeTooltip();
};
// On Click, toggle selection of the feature
const toggleFeature = (e) => {
if (e.target.feature.properties["lsoa11cd"] == selected_lsoa) {
e.target.setStyle(styles.choropleth);
mutable selected_lsoa = 'All';
} else {
e.target.setStyle(styles.select);
mutable selected_lsoa = String(e.target.feature.properties["lsoa11cd"]);
}
};
const onEachFeature = (feature, layer) => {
let formatted_colorvar = analysis === 'Best Housing Affordable' ? housing_types_numeric_converter[feature.properties[colorvar]] : formatPrice(feature.properties[colorvar]);
let tooltipContent = `
<strong style="font-size: 1.2em; color: rgb(0,42,94);">${analysis} per Earner:<br>${formatted_colorvar || 'N/A'}</strong><br>
<br>
<strong style="font-size: 1.2em; color: rgb(0,42,94);">Location</strong><br>
<strong>LSOA Name:</strong> ${feature.properties['lsoa11nm'] || 'N/A'}<br>
<strong>Local Authority:</strong> ${feature.properties['lad24nm'] || 'N/A'}<br>
<br>
<strong style="font-size: 1.2em; color: rgb(0,42,94);">Commuting</strong><br>
${feature.properties['tfl'] !== 1 ? `
<strong>Best Station:</strong> ${feature.properties['stationname'] || 'N/A'}<br>
<strong>Drive to Station:</strong> ${Math.round(feature.properties['tt_drive_time'], 0) || 'N/A'} minutes<br>
<strong>Outbound Train Time:</strong> ${Math.round(feature.properties['tt_outbound'], 0) || 'N/A'} minutes<br>
<strong>Inbound Train Time:</strong> ${Math.round(feature.properties['tt_inbound'], 0) || 'N/A'} minutes<br>` : ''}
<strong>Best Commute Method:</strong> ${feature.properties['best_commute_method'] || 'N/A'}<br>
<strong>Monthly Commute Costs:</strong> £${feature.properties[`monthly_pp_commute_cost_${hhld.value}_hhld`] ? Math.round(feature.properties[`monthly_pp_commute_cost_${hhld.value}_hhld`]).toLocaleString() : 'N/A'}<br>
<br>
<strong style="font-size: 1.2em; color: rgb(0,42,94);">Shelter Costs</strong><br>
<strong>${housing_type.label}:</strong> ${formatPrice(feature.properties[`${housing_type.value}_average_price`])}<br>
<br>
<strong style="font-size: 1.2em; color: rgb(0,42,94);">Education Outcomes</strong><br>
<strong>Primary:</strong> ${Math.round(feature.properties['primary'])}%<br>
<strong>Secondary:</strong> ${Math.round(feature.properties['secondary'])}%<br>
<strong>6th Form:</strong> ${Math.round(feature.properties['tertiary'])}%<br>
`;
layer.bindTooltip(tooltipContent);
layer.on({
mouseover: highlightFeature,
mouseout: resetHighlight,
click: toggleFeature,
});
layer.bringToBack(); // Ensure this layer is on top
};
// Check if we have already labeloaded the data, and cache it if not;
if (!cached_lsoa_data) {
console.log('**** ------> LOADING LSOA Data ****');
mutable cached_lsoa_data = await d3.json(urls['lsoa_layer']);
} else {
console.log('#### Using Cached LSOA Data ####');
}
// Filter Data
console.log('Filtering...');
let lsoa_short = {
...cached_lsoa_data,
features: cached_lsoa_data.features.filter(p2 => p2.properties.tt_category == commute_mode.value)
};
// Define Layer
let lsoa_layer = L.geoJSON(lsoa_short, {
style: function(feature) { return styleFunction(feature, colorvar); },
onEachFeature: onEachFeature,
name: 'LSOA Layer'
});
return lsoa_layer;
}
async function triggerUpdate(lsoa_map, commute_mode, analysis, budget, housing_type, children, selected_lsoa) {
// Generate new LSOA layer
const lsoa_layer = await loadAndFilterLSOA(commute_mode, analysis, budget, housing_type, children, selected_lsoa);
console.log("LSOA Layer Triggered");
if (lsoa_layer) {
console.log("Adding LSOA Layer...");
all_optional_layers["LSOA Layer"] = lsoa_layer;
}
// Generate new station layer
// Stations - Add to Map
const station_layer = await loadAndFilterStations(commute_mode, {}, null).then(station_layer => {
console.log("Stations Triggered")
if (station_layer) {
all_optional_layers["Stations"] = station_layer;
}
});
let update_response = updateMap(lsoa_map, layerOrder, optional_layers_selected, highlight_stations);
// Assign trigger
lsoa_layer.on("click", (e) => { mutable trigger += 1; });
}
dummy_variable_4 = triggerUpdate( lsoa_map,
commute_mode,
analysis,
budget,
housing_type,
children,
selected_lsoa);
function colorScale_thresh_salary(value) {
if (value == null || isNaN(value)) {
return "#a1a1a1"; // Dark grey if value is missing or none
}
switch (true) {
case value < 50000:
return blpu[0];
case value >= 50000 && value < 75000:
return blpu[1];
case value >= 75000 && value < 100000:
return blpu[2];
case value >= 100000 && value < 125000:
return blpu[3];
case value >= 125000 && value < 150000:
return blpu[4];
default:
return blpu[5];
}
}
function colorScale_best_housing(value) {
if (value == null || isNaN(value)) {
return "#a1a1a1"; // Dark grey if value is missing or none
}
switch (value) {
case 1:
return "#edf8fb";
case 2:
return "#b3cde3";
case 3:
return "#8c96c6";
case 4:
return "#8856a7";
case 5:
return "#810f7c";
default:
return "#ffffff"; // Default color (white) if value is outside 1-5
}
}
// Refactored styleFunction to avoid redundant function calls
function styleFunction(feature, colorvar) {
// Choose the appropriate color scale function based on 'analysis'
const color = (analysis === "Threshold Salary")
? colorScale_thresh_salary(feature.properties[colorvar])
: colorScale_best_housing(feature.properties[colorvar]);
// Return the style object, reusing the color for both fillColor and color
return {
...styles.choropleth,
color: color,
opactiy: opac,
fillOpacity: opac,
fillColor: color
};
}
function makeLegend(analysis, optional_layers_selected) {
// Create a new legend control
const legend = L.control({ position: 'bottomright' });
// Define the function to generate the content of the legend
legend.onAdd = function () {
// Create a container for the legend with improved styles
let div = L.DomUtil.create('div', 'info legend');
let legendHTML = '<div style="background: white; padding: 10px; border-radius: 8px; box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);">';
// Define the legend content based on the value of 'analysis'
let grades, labels, colors;
if (analysis === "Threshold Salary") {
// Threshold Salary legend
grades = [
`< £50,000`,
`≥ £50,000 and < £75,000`,
`≥ £75,000 and < £100,000`,
`≥ £100,000 and < £125,000`,
`≥ £125,000 and < £150,000`,
`≥ £150,000`,
`No Housing of this Type`
];
colors = blpu.concat(["#a1a1a1"]);
// Add a title for the legend
legendHTML += `<strong>Threshold Salary Required: <br>${housing_type.label}</strong><br><br>`;
// Generate labels and color boxes for the salary ranges
labels = grades.map((grade, index) => {
return `<i style="background:${colors[index]}; width: 18px; height: 18px; display: inline-block; margin-right: 8px; border-radius: 3px; border: 2px solid #808080;"></i> ${grade}`;
});
} else {
// Best Housing Affordable legend
grades = ["Detached House", "Semi-Detached House", "Terraced House", "Flat", "Renting the Only Option", "Not available"];
colors = ["#edf8fb", "#b3cde3", "#8c96c6", "#8856a7", "#810f7c", "#a1a1a1"];
// Add a title for the housing legend
legendHTML += '<strong>Best Housing Affordable</strong><br><br>';
// Generate labels and color boxes for housing types
labels = grades.map((grade, index) => {
return `<i style="background:${colors[index]}; width: 18px; height: 18px; display: inline-block; margin-right: 8px; border-radius: 3px; border: 2px solid #808080;"></i> ${grade}`;
});
}
// Construct the main part of the legend (the grades/labels)
legendHTML += labels.join('<br>');
// Add markers for Railways and Stations at the bottom, only if present in optional_layers_selected
let showContextLayers = optional_layers_selected.includes('Stations') || optional_layers_selected.includes('Railways');
// Add the header only if either Railways or Stations is selected
if (showContextLayers) {
legendHTML += `
<div style="height: 10px;"></div>
<strong> Context</strong><br>
`;
}
// Add the marker for Stations if selected
if (optional_layers_selected.includes('Stations')) {
legendHTML += `
<i style="background:${styles.stations.fillColor || 'black'}; width: 6px; height: 6px; display: inline-block; border-radius: 50%; margin-right: 22px; border: none;"></i>
Stations<br>
`;
}
// Add the marker for Railways if selected
if (optional_layers_selected.includes('Railways')) {
legendHTML += `
<i style="background:${styles.railways.color}; width: 20px; height: 2px; display: inline-block; margin-right: 8px;"></i>
Railways<br>
`;
}
// Close the legend container
legendHTML += '</div>';
div.innerHTML = legendHTML;
return div;
};
// Return the legend in case you need to manipulate it later
return legend;
}
display_result_of_click = {
trigger;
let phrase = "DISPLAY selected_lsoa " + trigger + " - " + selected_lsoa + " - " + countLayers(lsoa_map)
console.log(phrase);
return phrase
}
// Stations - Layer Preparation
async function loadAndFilterStations(commute_mode, tooltip_options, station_subset) {
try {
console.log('**** ------> LOADING Station Geometry Data ****');
let stations = await d3.json(urls['stations']);
let stations_short = {
...stations,
features: stations.features.filter(p2 => {
// Check if the station's commute mode matches
// let matchesCommuteMode = p2.properties.tt_category === commute_mode.value;
// If station_subset is provided (not null), also check if the station's crs_code is in the subset
let inSubset = station_subset ? station_subset.includes(p2.properties.crs_code) : true;
// Return true if both conditions are satisfied
// return matchesCommuteMode && inSubset;
return inSubset; // removed commute mode filtering
})
};
let stationLayer = L.geoJSON(stations_short, {
style: station_subset ? styles.highlighted_stations : styles.stations,
pointToLayer: function(feature, latlng) {
return L.circleMarker(latlng);
},
onEachFeature: function(feature, layer) {
let tooltipContent = `
<strong>${feature.properties.stationname} (${feature.properties.crs_code})</strong>
`;
layer.bindTooltip(tooltipContent, tooltip_options);
layer.on('mouseover', function(e) {
layer.openTooltip();
});
layer.on('mouseout', function(e) {
layer.closeTooltip();
});
},
name: 'Stations'
});
return stationLayer;
} catch (error) {
console.error('Error loading the Station GeoJSON data:', error);
}
}
async function loadRailways() {
try {
console.log('**** ------> LOADING Rail Line Geometry Data ****');
let rails = await d3.json(urls['rail_lines']);
let railLayer = L.geoJSON(rails, {
style: styles.railways,
name: 'Railways'
})
return railLayer;
} catch (error) {
console.error('Error loading the Railway GeoJSON data:', error);
}
}
dummy_variable_3 = loadRailways().then(railLayer => {
console.log("Railways Triggered")
if (railLayer) {
all_optional_layers['Railways'] = railLayer;
}
});
Top Stations
Use the table to identify, for a given commuting strategy, target property type and family composition, which stations are best for educational outcomes at the primary, secondary and sixth form stages.
Filtering by Gross Income per Earner is optional. An empty table may be returned if the Gross Income per Earner is not large enough to support the lifestyle specified in the assumptions if “Filter Results by Budget” is set to “On”
Click on any column header to sort the table by that column
viewof commute_mode_t10 = Inputs.select( commute_modes,
{ value: 'Daily',
format: x => x.label,
multiple: false
});
// Desired Housing
viewof housing_type_t10 = Inputs.select( housing_types,
{ value: 'detached',
format: x => x.label,
multiple: false,
});
// Affordability Filter
viewof filter_affordability = Inputs.radio(filter_by_affordability_options,
{ value: filter_by_affordability_options[0],
format: x => x.label,
multiple: false,
});
budget_disabled = filter_affordability.value === 0 ? true : false;
// Budget
viewof budget_t10 = Inputs.range([50000,300000],{
step: 5000,
value: 80000,
disabled: budget_disabled});
// Number of Children
viewof children_t10 = Inputs.select( children_options,
{ format: x => x.label,
value: 0,
multiple: false
});
// Number of Earners in Household
viewof hhld_t10 = Inputs.select( hhld_options,
{ format: x => x.label,
value: 2,
multiple: false
});
// Order Control
viewof order_by = Inputs.select(school_options,
{ format: x => x.label,
value: 'primary',
multiple: false
});
inputs_left_t10 = [
{ label: "Commuting Strategy:", input: viewof commute_mode_t10},
{ label: "Target Proprty Type:", input: viewof housing_type_t10},
{ label: "Number of Earners:", input: viewof hhld_t10},
{ label: "Number of Children of Nursery Age:", input: viewof children_t10},
{ label: "Order by:", input: viewof order_by},
{ label: "<i>Optional:</i> Filter Results by Budget?", input: viewof filter_affordability},
{ label: "Gross Income per Earner:", input: viewof budget_t10},
];
// Create the table with invisible borders and explicit widths
html`<table style="border-collapse: collapse; border: none;">
${inputs_left_t10.map(({ label, input }) => html`
<tr style="border: none; background-color: #dbebff;">
<td style="width: ${labelWidth}px; white-space: nowrap; padding-right: 20px; text-align: left; border: none;">${label}</td>
<td style="width: ${dropdownWidth}px; text-align: left; border: none; padding-top: 8px; padding-bottom: 8px;">${input}</td>
</tr>
`)}
</table>
`;
async function loadTableData(
commute_mode_t10,
housing_type_t10,
children_t10,
hhld_t10,
budget_t10,
filter_affordability,
order_by
) {
// Create the 'budget_field' dynamically
let budget_field = `${housing_type_t10.value}_gross_pp_req_${children_t10.value}_kid_${hhld_t10.value}_hhld`;
// Check if we have already labeloaded the data, and cache it if not;
if (!cached_lsoa_data) {
console.log('**** ------> LOADING LSOA Data ****');
mutable cached_lsoa_data = await d3.json(urls['lsoa_layer']);
} else {
console.log('#### Using Cached LSOA Data ####');
}
// Ensure that budget_field exists
if (!cached_lsoa_data.features[0].properties.hasOwnProperty(budget_field)) {
console.error(`Error: ${budget_field} does not exist in the dataset.`);
return [];
}
// List of desired properties
const selectedProperties = [
'tt_category',
'stationname',
'primary',
'secondary',
'tertiary',
`${housing_type_t10.value}_average_price`,
'avg_train_journey_mins',
budget_field
];
let cached_lsoa_data_ = cached_lsoa_data['features'].map(r => {
let subset = {};
selectedProperties.forEach(prop => {
subset[prop] = r.properties[prop]; // Copy only the selected properties
});
return subset;
});
const outcols = [
'stationname',
'primary', 'secondary', 'tertiary',
`${housing_type_t10.value}_average_price`,
'avg_train_journey_mins',
];
let ecsch = cached_lsoa_data_
.filter(row => {
// Drop rows with null or undefined values in any of the specified columns
return outcols.every(col => row[col] !== null && row[col] !== undefined);
})
.filter(row => row.tt_category === commute_mode_t10.value) // Filter by commute mode
.filter(row => filter_affordability.value !== 1 || row[budget_field] < budget_t10) // Filter by affordability if required
.map(row => ({
...row,
income_per_earner: row[budget_field] // Add 'income_per_earner' field
}))
.sort((a, b) => {
// Sort by 'stationname' first, then by 'income_per_earner' if stationnames are the same
if (a.stationname === b.stationname) {
return a.income_per_earner - b.income_per_earner;
}
return a.stationname.localeCompare(b.stationname);
});
// Group by 'stationname' and take the first row for each group
let groupedEcsch = [];
let stationNames = new Set(); // Set to track which station names have been processed
ecsch.forEach(row => {
if (!stationNames.has(row.stationname)) {
groupedEcsch.push(row); // Add the first occurrence
stationNames.add(row.stationname);
}
});
let gecsch = groupedEcsch;
// Sort by order_by.value in descending order
gecsch = gecsch.sort((a, b) => b[order_by.value] - a[order_by.value]);
let outframe = gecsch
.filter(row => {
// Drop rows with null or undefined values in any of the specified columns
return outcols.every(col => row[col] !== null && row[col] !== undefined);
})
.map((row, index) => {
// Add the rank column based on the order of rows
row['rank'] = index + 1; // Assign rank starting from 1
return row;
});
return outframe;
}
function makeRankTable(frame,
housing_type_t10) {
let t = Inputs.table(frame, {
columns: [
"rank",
"stationname",
'primary',
'secondary',
'tertiary',
housing_type_t10.value+"_average_price",
'avg_train_journey_mins',
'income_per_earner',
],
header: {
rank: "Rank",
stationname: "Station",
primary: "Primary Score",
secondary: "Secondary Score",
tertiary: "Sixth Form Score",
[housing_type_t10.value+"_average_price"]: "Avg. "+housing_type_t10.label+" Price",
avg_train_journey_mins: "Avg. Travel Time From Station to Piccadilly Circus",
income_per_earner: "Minimum Gross Income Required per Earner"
},
layout: "fixed",
width: "100%",
maxWidth: "100%",
selectable: false,
select: false,
format: {
rank: x => String(x),
primary: x => x.toFixed(1),
secondary: x => x.toFixed(1),
tertiary: x => x.toFixed(1),
[housing_type_t10.value+"_average_price"]: x => formatPrice(x),
avg_train_journey_mins: x => x.toFixed(0),
income_per_earner: x => formatPrice(x)
},
align: {
rank: "left"
}
})
return t;
}
Our interactive map has been optimised for use exclusively on desktop and tablet devices only.