Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Local postcode lookup #563

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions hub/static/js/mini_lookup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
const bisectLeft = (arr, x) => {
let left = 0;
let right = arr.length;
while (left < right) {
const mid = (left + right) >> 1;
if (arr[mid] < x) left = mid + 1;
else right = mid;
}
return left;
};

const postcodeRegex = /^(?:[A-Z]{2}[0-9][A-Z]|[A-Z][0-9][A-Z]|[A-Z][0-9]|[A-Z][0-9]{2}|[A-Z]{2}[0-9]|[A-Z]{2}[0-9]{2})[0-9][A-Z]{2}$/;

const checkRealPostcode = (postcode) => {
return postcodeRegex.test(postcode.replace(/\s/g, "").toUpperCase());
};

class StoredData {
constructor(postcodeKeys, valueKey, valueValues) {
this.postcodeKeys = postcodeKeys;
this.valueKey = valueKey;
this.valueValues = valueValues;
}
}

const reverseDifferenceCompression = (listA) => {
let result = [];
let lastValue = 0;
for (let value of listA) {
lastValue += value;
result.push(lastValue);
}
return result;
};

const reverseDropMinusOne = (listA) => {
let result = listA.slice(0, 2);
for (let value of listA.slice(2)) {
if (value === 0) {
result.push(result[result.length - 2]);
} else {
result.push(value);
}
}
return result.map(x => x - 1);
};

const postcodeToInt = (postcode) => {
return parseInt(postcode.replace(/\s/g, "").toUpperCase(), 36);
};

class PostcodeRangeLookup {
constructor(postcodeKeys, valueKey, valueValues) {
this.postcodeKeys = postcodeKeys;
this.valueKey = valueKey;
this.valueValues = valueValues;
}

getValue(postcode, checkValidPostcode = true) {
if (checkValidPostcode && !checkRealPostcode(postcode)) {
return null;
}

// if starts with BT - NI, return null
if (postcode.toUpperCase().startsWith("BT")) {
return null;
}

const intPostcode = postcodeToInt(postcode);
let left = bisectLeft(this.postcodeKeys, intPostcode);
if (left === 0 && (intPostcode !== this.postcodeKeys[0])) {
return null;
}

if (left < this.postcodeKeys.length && this.postcodeKeys[left] !== intPostcode) {
left -= 1;
}

if (left == this.postcodeKeys.length) {
left -= 1;
}

const valueIndex = this.valueKey[left];
if (valueIndex === -1 || valueIndex >= this.valueValues.length) {
return null;
} else {
return this.valueValues[valueIndex];
}
}

static fromDict(data) {
return new PostcodeRangeLookup(
reverseDifferenceCompression(data.postcode_keys),
reverseDropMinusOne(data.value_key),
data.value_values
);
}

static async fromJson(path) {
const response = await fetch(path);
const data = await response.json();
return PostcodeRangeLookup.fromDict(data);
}
}
1 change: 1 addition & 0 deletions hub/static/lookup_data/pcon_2024.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions hub/static/lookup_data/pcon_2024_lookup.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions hub/templates/hub/includes/header.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<ul class="navbar-nav ms-auto flex-wrap justify-content-end">
{% include 'hub/includes/nav-item.html' with name='home' label='Search' %}
{% include 'hub/includes/nav-item.html' with name='explore' label='Map' %}
{% include 'hub/includes/nav-item.html' with name='postcode_converter' label='Tools' %}
{% if user.is_authenticated %}
{% include 'hub/includes/nav-item.html' with name='my_account' label='Account' %}
{% else %}
Expand Down
169 changes: 169 additions & 0 deletions hub/templates/hub/tools/postcode.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
{% extends "hub/base.html" %}

{% block content %}

<style>
.textbox {
width: 100%;
height: 300px;
}
</style>
<div class="py-4 py-lg-5">
<div class="container">
<div class="readable">

<h1>Postcode to Constituency Tool</h1>

<p>This tool converts a column of GB postcodes into a column of new (2024) constituencies.</p>

<p>Paste a column from Excel or Google Sheets into the left-hand box, and constituencies will appear in the right-hand box.</p>

<p>Copy these back to your spreadsheet, and you're done. For more complex approaches, use <a href="https://mapit.mysociety.org">MapIt</a>.</p>

<p>This all happens on your own computer, the postcodes are not sent to our server.</p>



<div class="form-group" style="padding-bottom:20px">
<div class="row">
<div class="col">
<textarea id="postcodes" class="form-control textbox" placeholder="Enter postcodes"></textarea>
</div>
<div class="col">
<textarea id="constituencies" class="form-control textbox" readonly></textarea>
</div>
</div>
</div>


<div class="row">
<div class="col">
<div class="form-group">
<label for="output_type"><strong>Output Type</strong></label>
<div class="form-check">
<input class="form-check-input" type="radio" id="parlcon_2024_name" name="output_type" value="parlcon_2024_name" checked>
<label class="form-check-label" for="parlcon_2024_name">Constituency name</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" id="mysoc_id" name="output_type" value="mysoc_id">
<label class="form-check-label" for="mysoc_id">mySociety ID</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" id="parlcon_2024_gss_code" name="output_type" value="parlcon_2024_gss_code">
<label class="form-check-label" for="parlcon_2024_gss_code">GSS code</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" id="parlcon_2024_three_code" name="output_type" value="parlcon_2024_three_code">
<label class="form-check-label" for="parlcon_2024_three_code">Three code</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" id="parlcon_2024_name_cy" name="output_type" value="parlcon_2024_name_cy">
<label class="form-check-label" for="parlcon_2024_name_cy">Constituency name (Welsh)</label>
</div>


</div>
</div>
<div class="col">
<button id="copyButton" class="btn btn-primary">Copy Constituencies to clipboard</button>
</div>
</div>

<h2>More information</h2>

<ul>
<li>This tool is making our postcode lookup file accessible in a browser - this <a href="https://pages.mysociety.org/2025-constituencies/">is available to download</a>. </li>
<li>This tool does not work for postcodes in Northern Ireland due to licensing issues - <a href="https://mapit.mysociety.org/">Mapit</a> and its <a href="https://mapit.mysociety.org/bulk/">spreadsheet converter</a> can be used for Northern Ireland postcodes. </li>
<li>Postcode lookups are not accurate for all addresses - on (rare) occasions different addresses in the same postcode are in different constituencies</li>
<li>This lookup may be inaccurate for postcodes created in the last year. </li>



</div>

</div>
</div>
<script src="/static/js/mini_lookup.js" type="text/javascript"></script>
<script>
fetch("/static/lookup_data/pcon_2024.json")
.then(response => response.json())
.then(data => {
lookup = PostcodeRangeLookup.fromDict(data);
})
.catch(error => {
console.error("Error loading postcode lookup data:", error);
});

fetch("/static/lookup_data/pcon_2024_lookup.json")
.then(response => response.json())
.then(data => {
field_lookup = data
})
.catch(error => {
console.error("Error loading postcode lookup data:", error);
});

function log_count(numPostcodes) {
// if trackEvent is defined, log the number of postcodes processed
if (typeof trackEvent === "function") {
trackEvent("postcode_tool", {"count": numPostcodes});
}
}

function process() {
var output_type = document.querySelector('input[name="output_type"]:checked').value;

var postcodes = document.getElementById("postcodes").value.split("\n");
var constituencies = postcodes.map(postcode => lookup.getValue(postcode));

// if output_type is not mySoc ID, convert the constituency ids to the desired output
if (output_type !== "mysoc_id") {
constituencies = constituencies.map(constituency => {
if (constituency === null) {
return null;
}
return field_lookup[constituency][output_type];
});
}

// if the top row of constituencies is blank
// there is more than one row (suggesting the original top row was a header)
// make the top row a pcon_2024 header
if (constituencies[0] === null && constituencies.length > 1) {
constituencies[0] = "pcon_2024";
}

document.getElementById("constituencies").value = constituencies.join("\n");

log_count(postcodes.length);
}


document.getElementById("postcodes").addEventListener("input", process);
document.getElementsByName("output_type").forEach(function (element) {
element.addEventListener("input", process);
});

document.getElementById("copyButton").addEventListener("click", function () {
var constituenciesText = document.getElementById("constituencies").value;
navigator.clipboard.writeText(constituenciesText)
.then(() => {
console.log("Constituencies copied to clipboard");
})
ajparsons marked this conversation as resolved.
Show resolved Hide resolved
.catch(error => {
console.error("Error copying constituencies to clipboard:", error);
});
// change the copyButton class to btn-success and its text to 'Copied to clipboard'
// for two seconds before reverting to the original class and text
document.getElementById("copyButton").classList.remove("btn-primary");
document.getElementById("copyButton").classList.add("btn-success");
document.getElementById("copyButton").textContent = "Copied to clipboard";
setTimeout(function () {
document.getElementById("copyButton").classList.remove("btn-success");
document.getElementById("copyButton").classList.add("btn-primary");
document.getElementById("copyButton").textContent = "Copy Constituencies to clipboard";
}, 500);
ajparsons marked this conversation as resolved.
Show resolved Hide resolved
});
</script>

{% endblock %}
14 changes: 14 additions & 0 deletions hub/views/tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.views import View


class PostcodeView(View):
ajparsons marked this conversation as resolved.
Show resolved Hide resolved
def get(self, request: HttpRequest) -> HttpResponse:
ajparsons marked this conversation as resolved.
Show resolved Hide resolved

context = {
"page_title": "Postcode to Constituency Tool",
"meta_description": "Add the new 2024 constituencies to your postcode data.",
}

return render(request, "hub/tools/postcode.html", context)
3 changes: 2 additions & 1 deletion local_intelligence_hub/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from django.views.generic.base import RedirectView

from hub.sitemap import hub_sitemap
from hub.views import accounts, area, core, explore, landingpages
from hub.views import accounts, area, core, explore, landingpages, tools

handler404 = core.NotFoundPageView.as_view()

Expand Down Expand Up @@ -57,6 +57,7 @@
area.UnFavouriteDataSetView.as_view(),
name="unfavourite_dataset",
),
path("tools/postcode", tools.PostcodeView.as_view(), name="postcode_converter"),
ajparsons marked this conversation as resolved.
Show resolved Hide resolved
path("sources/", core.SourcesView.as_view(), name="sources"),
path(
"future-constituencies/",
Expand Down
Loading