Skip to content

Commit 2cc736c

Browse files
authored
Merge pull request #297 from commonknowledge/feat/wards
Feat/wards
2 parents fcdab5d + 85f4cb4 commit 2cc736c

File tree

15 files changed

+672
-51
lines changed

15 files changed

+672
-51
lines changed

bin/cmd.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { Command } from "commander";
22
import { SignJWT } from "jose";
33
import ensureOrganisationMap from "@/server/commands/ensureOrganisationMap";
44
import importConstituencies from "@/server/commands/importConstituencies";
5+
import importLADs from "@/server/commands/importLADs";
56
import importMSOAs from "@/server/commands/importMSOAs";
67
import importOutputAreas from "@/server/commands/importOutputAreas";
78
import importPostcodes from "@/server/commands/importPostcodes";
89
import importRegions from "@/server/commands/importRegions";
10+
import importWards from "@/server/commands/importWards";
911
import regeocode from "@/server/commands/regeocode";
1012
import removeDevWebhooks from "@/server/commands/removeDevWebhooks";
1113
import Invite from "@/server/emails/invite";
@@ -65,6 +67,20 @@ program
6567
await importConstituencies();
6668
});
6769

70+
program
71+
.command("importLADs")
72+
.description("Import Local Area Districts")
73+
.action(async () => {
74+
await importLADs();
75+
});
76+
77+
program
78+
.command("importWards")
79+
.description("Import UK Wards")
80+
.action(async () => {
81+
await importWards();
82+
});
83+
6884
program
6985
.command("importRegions")
7086
.description("Import English Regions & Nations")

src/app/map/[id]/components/BoundaryHoverInfo/AreasList.tsx

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -112,22 +112,20 @@ export function AreasList({
112112
? areaStats.stats.find((s) => s.areaCode === area.code)
113113
: null;
114114

115-
const primaryValue =
116-
areaStats && areaStat
117-
? getDisplayValue(
118-
areaStats.calculationType,
119-
areaStats.primary,
120-
areaStat.primary,
121-
)
122-
: null;
123-
const secondaryValue =
124-
areaStats && areaStat
125-
? getDisplayValue(
126-
areaStats.calculationType,
127-
areaStats.secondary,
128-
areaStat.secondary,
129-
)
130-
: null;
115+
const primaryValue = areaStats
116+
? getDisplayValue(
117+
areaStats.calculationType,
118+
areaStats.primary,
119+
areaStat?.primary,
120+
)
121+
: null;
122+
const secondaryValue = areaStats
123+
? getDisplayValue(
124+
areaStats.calculationType,
125+
areaStats.secondary,
126+
areaStat?.secondary,
127+
)
128+
: null;
131129

132130
return (
133131
<TableRow

src/app/map/[id]/components/Choropleth/configs.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,43 @@ export const CHOROPLETH_LAYER_CONFIGS: Record<
4545
},
4646
},
4747
],
48+
W25: [
49+
{
50+
areaSetCode: AreaSetCode.W25,
51+
minZoom: 6,
52+
requiresBoundingBox: true,
53+
mapbox: {
54+
featureCodeProperty: "WD25CD",
55+
featureNameProperty: "WD25NM",
56+
layerId: "wards",
57+
sourceId: "commonknowledge.9cnmf4m1",
58+
},
59+
},
60+
{
61+
areaSetCode: AreaSetCode.LAD25,
62+
minZoom: 2,
63+
requiresBoundingBox: false,
64+
mapbox: {
65+
featureCodeProperty: "LAD25CD",
66+
featureNameProperty: "LAD25NM",
67+
layerId: "lads",
68+
sourceId: "commonknowledge.3nsunzct",
69+
},
70+
},
71+
],
72+
LAD25: [
73+
{
74+
areaSetCode: AreaSetCode.LAD25,
75+
minZoom: 2,
76+
requiresBoundingBox: false,
77+
mapbox: {
78+
featureCodeProperty: "LAD25CD",
79+
featureNameProperty: "LAD25NM",
80+
layerId: "lads",
81+
sourceId: "commonknowledge.3nsunzct",
82+
},
83+
},
84+
],
4885
OA21: [
4986
{
5087
areaSetCode: AreaSetCode.OA21,

src/app/map/[id]/components/inspector/BoundaryDataPanel.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import DataSourceIcon from "@/components/DataSourceIcon";
66
import { getDataSourceType } from "@/components/DataSourceItem";
77
import { AreaSetCode } from "@/server/models/AreaSet";
88
import { useTRPC } from "@/services/trpc/react";
9+
import { DataRecordMatchType } from "@/types";
910
import { buildName } from "@/utils/dataRecord";
1011
import { useDataSources } from "../../hooks/useDataSources";
1112
import PropertiesList from "./PropertiesList";
@@ -56,17 +57,25 @@ export function BoundaryDataPanel({
5657
<div className="py-4 text-center text-muted-foreground">
5758
<p className="text-sm">Loading...</p>
5859
</div>
59-
) : data?.length === 1 ? (
60-
<BoundaryDataProperties json={data[0].json} columns={columns} />
61-
) : data?.length ? (
60+
) : data?.records.length === 1 ? (
61+
<BoundaryDataProperties
62+
json={data.records[0].json}
63+
columns={columns}
64+
match={data.match}
65+
/>
66+
) : data?.records.length ? (
6267
<ul className="ml-2">
63-
{data.map((d, i) => (
68+
{data.records.map((d, i) => (
6469
<li key={d.id}>
6570
<TogglePanel
6671
label={buildName(dataSource, d)}
6772
defaultExpanded={i === 0}
6873
>
69-
<BoundaryDataProperties json={d.json} columns={columns} />
74+
<BoundaryDataProperties
75+
json={d.json}
76+
columns={columns}
77+
match={data.match}
78+
/>
7079
</TogglePanel>
7180
</li>
7281
))}
@@ -83,9 +92,11 @@ export function BoundaryDataPanel({
8392
function BoundaryDataProperties({
8493
json,
8594
columns,
95+
match,
8696
}: {
8797
json: Record<string, unknown>;
8898
columns: string[];
99+
match: DataRecordMatchType;
89100
}) {
90101
const filteredProperties = useMemo(() => {
91102
const filtered: Record<string, unknown> = {};
@@ -98,6 +109,11 @@ function BoundaryDataProperties({
98109
}, [columns, json]);
99110
return (
100111
<div className="ml-6">
112+
{match === DataRecordMatchType.Approximate && (
113+
<p className="text-sm text-muted-foreground mb-2 italic">
114+
Approximate boundary match
115+
</p>
116+
)}
101117
{Object.keys(filteredProperties).length > 0 ? (
102118
<PropertiesList properties={filteredProperties} />
103119
) : (

src/labels.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,20 @@ import type z from "zod";
1616

1717
export const AreaSetCodeLabels: Record<AreaSetCode, string> = {
1818
PC: "UK Postcode",
19-
OA21: "Census Output Area (2021)",
20-
MSOA21: "Middle Super Output Area (2021)",
21-
UKR18: "UK Regions (2018)",
2219
WMC24: "Westminster Constituency (2024)",
20+
LAD25: "Local Authority District (2025)",
21+
W25: "Ward (2025)",
22+
MSOA21: "Middle Super Output Area (2021)",
23+
OA21: "Census Output Area (2021)",
24+
UKR18: "UK Region (2018)",
2325
};
2426

2527
export const AreaSetGroupCodeLabels: Record<AreaSetGroupCode, string> = {
26-
OA21: "Census Output Area (2021)",
27-
UKR18: "UK Regions (2018)",
2828
WMC24: "Westminster Constituency (2024)",
29+
LAD25: "Local Authority District (2025)",
30+
W25: "Ward (2025)",
31+
OA21: "Census Output Area (2021)",
32+
UKR18: "UK Region (2018)",
2933
};
3034

3135
type DataSourceConfigKey =

src/server/commands/importLADs.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import fs from "fs";
2+
import { join } from "path";
3+
import { sql } from "kysely";
4+
import { AreaSetCode } from "@/server/models/AreaSet";
5+
import {
6+
createAreaSet,
7+
findAreaSetByCode,
8+
} from "@/server/repositories/AreaSet";
9+
import { db } from "@/server/services/database";
10+
import logger from "@/server/services/logger";
11+
import { getBaseDir } from "@/server/utils";
12+
13+
const AREA_SET_CODE = AreaSetCode.LAD25;
14+
15+
const importLADs = async () => {
16+
const lasGeojsonPath = join(
17+
getBaseDir(),
18+
"resources",
19+
"areaSets",
20+
"lads.geojson",
21+
);
22+
if (!fs.existsSync(lasGeojsonPath)) {
23+
logger.error(
24+
`File not found: ${lasGeojsonPath}. Download from https://www.data.gov.uk/dataset/bde3b6d9-23a7-4bf6-bb55-df7b439b713a/local-authority-districts-may-2025-boundaries-uk-bgc-v2`,
25+
);
26+
return;
27+
}
28+
let areaSet = await findAreaSetByCode(AREA_SET_CODE);
29+
if (!areaSet) {
30+
areaSet = await createAreaSet({
31+
name: "Local Authority Districts 2025",
32+
code: AREA_SET_CODE,
33+
});
34+
logger.info(`Inserted area set ${AREA_SET_CODE}`);
35+
} else {
36+
logger.info(`Using area set ${AREA_SET_CODE}`);
37+
}
38+
const geojson = fs.readFileSync(lasGeojsonPath, "utf8");
39+
const areas = JSON.parse(geojson) as {
40+
features: {
41+
properties: { LAD25CD: string; LAD25NM: string };
42+
geometry: unknown;
43+
}[];
44+
};
45+
const count = areas.features.length;
46+
for (let i = 0; i < count; i++) {
47+
const feature = areas.features[i];
48+
const code = feature.properties.LAD25CD;
49+
const name = feature.properties.LAD25NM;
50+
await sql`INSERT INTO area (name, code, geography, area_set_id)
51+
VALUES (
52+
${name},
53+
${code},
54+
ST_Transform(
55+
ST_SetSRID(
56+
ST_GeomFromGeoJSON(${JSON.stringify(feature.geometry)}),
57+
27700 -- Set the original EPSG:27700 (British National Grid)
58+
),
59+
4326 -- Convert to WGS 84
60+
)::geography,
61+
${areaSet.id}
62+
)
63+
ON CONFLICT (code, area_set_id) DO UPDATE SET geography = EXCLUDED.geography;
64+
`.execute(db);
65+
66+
const percentComplete = Math.floor((i * 100) / count);
67+
logger.info(`Inserted area ${code}. ${percentComplete}% complete`);
68+
}
69+
};
70+
71+
export default importLADs;

src/server/commands/importWards.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import fs from "fs";
2+
import { join } from "path";
3+
import { sql } from "kysely";
4+
import { AreaSetCode } from "@/server/models/AreaSet";
5+
import {
6+
createAreaSet,
7+
findAreaSetByCode,
8+
} from "@/server/repositories/AreaSet";
9+
import { db } from "@/server/services/database";
10+
import logger from "@/server/services/logger";
11+
import { getBaseDir } from "@/server/utils";
12+
13+
const AREA_SET_CODE = AreaSetCode.W25;
14+
15+
const importWards = async () => {
16+
const wardsGeojsonPath = join(
17+
getBaseDir(),
18+
"resources",
19+
"areaSets",
20+
"wards.geojson",
21+
);
22+
if (!fs.existsSync(wardsGeojsonPath)) {
23+
logger.error(
24+
`File not found: ${wardsGeojsonPath}. Download from https://geoportal.statistics.gov.uk/datasets/6ba7cf950a504d82809131c945fe70f1_0/about`,
25+
);
26+
return;
27+
}
28+
let areaSet = await findAreaSetByCode(AREA_SET_CODE);
29+
if (!areaSet) {
30+
areaSet = await createAreaSet({
31+
name: "UK Wards 2025",
32+
code: AREA_SET_CODE,
33+
});
34+
logger.info(`Inserted area set ${AREA_SET_CODE}`);
35+
} else {
36+
logger.info(`Using area set ${AREA_SET_CODE}`);
37+
}
38+
const geojson = fs.readFileSync(wardsGeojsonPath, "utf8");
39+
const areas = JSON.parse(geojson) as {
40+
features: {
41+
properties: { WD25CD: string; WD25NM: string };
42+
geometry: unknown;
43+
}[];
44+
};
45+
const count = areas.features.length;
46+
for (let i = 0; i < count; i++) {
47+
const feature = areas.features[i];
48+
const code = feature.properties.WD25CD;
49+
const name = feature.properties.WD25NM;
50+
await sql`INSERT INTO area (name, code, geography, area_set_id)
51+
VALUES (
52+
${name},
53+
${code},
54+
ST_SetSRID(
55+
ST_GeomFromGeoJSON(${JSON.stringify(feature.geometry)}),
56+
4326
57+
)::geography,
58+
${areaSet.id}
59+
)
60+
ON CONFLICT (code, area_set_id) DO UPDATE SET geography = EXCLUDED.geography;
61+
`.execute(db);
62+
63+
const percentComplete = Math.floor((i * 100) / count);
64+
logger.info(`Inserted area ${code}. ${percentComplete}% complete`);
65+
}
66+
};
67+
68+
export default importWards;

0 commit comments

Comments
 (0)