NSW 2023 legislative districts to postcodes: correspondence file & mapping application

We derive the correspondence between NSW legislative districts and postcodes via a spatial merge, using the G-NAF and a MapInfo file of district boundaries from the NSW Electoral Commission. We also provide an interactive tabular summary and map of the correspondence.

Author

Simon Jackman

Published

9:33AM 16 November 2022

Inputs

Not included in this repo for size and/or license restrictions:

For making maps, we use the (very good) approximations to postcode geographies provided in shapefiles published by the Australian Bureau of Statistics.

Methodology

  • Every G-NAF address in NSW is geocoded using GDA94 or GDA 2020 CRS, the same CRS used for both the NSW legislative district MapInfo file and ABS postal area shape files.
  • Locate each geo-coded NSW address from G-NAF in a NSW legislative district, using functions in the sf R package.
  • For each postcode, compute and report count and what proportion of its geo-coded addresses lie in each district.
  • Use leaflet to build a JS mapping/visualization application.
  • quarto assembles this document and the web application, in turn utilising Observable JS for table building and the call to leaflet (Section 8).

Read G-NAF

Code
gnaf <- read_delim(file = gnaf_core, delim = "|")
nsw_addresses <- gnaf %>%
    rename_with(tolower) %>%
    filter(state == "NSW")
write.fst(nsw_addresses, path = here("data/gnaf_subset.fst"))
rm(gnaf)
Code
nsw_addresses <- read.fst(path = here("data/gnaf_subset.fst"))
nsw_geo <- nsw_addresses %>% 
  distinct(postcode,longitude,latitude)

nsw_geo <- st_as_sf(nsw_geo,
                    coords = c('longitude', 'latitude'),
                    crs = st_crs(CRS("+init=EPSG:7843"))
                    )

We subset the G-NAF “core” file to NSW addresses, yielding 4,772,933 addresses, spanning 622 postcodes and 2,894,120 distinct geo-codes (latitude/longitude pairs).

NSW state legislative districts

We read the MapInfo file supplied by the NSW Electoral Commission; this employs the GDA2020 CRS and contain the boundaries for NSW’s 93 state legislative districts, to be used in the 2023 election (gazetted and proclaimed on 26 August 2021).

Code
nsw_shp <-
  st_read(
    dsn = here("data/StateElectoralDistrict2021_GDA2020.MID"),
    query = sprintf(
      "SELECT objectid, cadid, districtna from StateElectoralDistrict2021_GDA2020"
    )
  )
nsw_shp <- st_transform(nsw_shp, crs = CRS("+init=EPSG:7843"))
nsw_shp <- as(nsw_shp, "sf") %>% st_make_valid() 
geojsonio::geojson_write(nsw_shp,file = here("data/nsw_shp.json"))

Read postcode shapefiles

For map-making later on, we read the ABS postal areas (postcode) shapefiles; the coordinates use the GDA2020 geodetic CRS, which corresponds to EPSG:7843 CRS used in the other data sets we utilize.

Code
poa_shp <-
  st_read(
    here("data/POA_2021_AUST_GDA2020_SHP/POA_2021_AUST_GDA2020.shp"),
  )
st_crs(poa_shp) <- CRS("+init=EPSG:7843")
poa_shp <- st_transform(poa_shp, crs = CRS("+init=EPSG:7843"))
poa_shp <- as(poa_shp, "sf") %>% st_make_valid() 

Matching addresses to districts

We now match the unique, geo-coded NSW addresses to districts, the real “work” of this exercise being done by the call to sf::st_intersects in the function pfunc; we group the data by postcode and use process these batches of data in parallel via furrr::future_map.

Code
pfunc <- function(obj){
  z <- st_intersects(obj$geometry,nsw_shp)
  return(as.integer(z))
}

nsw_geo <- nsw_geo %>% 
  group_nest(postcode) %>% 
  mutate(
    intersection = future_map(.x = data,
                              .f = ~pfunc(.x))
    ) %>% 
  ungroup()

nsw_geo <- nsw_geo %>% 
  unnest(c(data,intersection))

nsw_geo <- nsw_geo %>% 
  mutate(district = nsw_shp$districtna[intersection])

We merge the results back against the GNAF addresses for NSW:

Code
coords <- st_coordinates(nsw_geo$geometry)
nsw_geo <- nsw_geo %>% 
  mutate(longitude = coords[,1],
         latitude = coords[,2]) %>% 
  select(-geometry) 

nsw_addresses <- left_join(nsw_addresses,
                           nsw_geo,
                           by = c("postcode", "longitude", "latitude"))
rm(nsw_geo,coords)

We also filter down to the subset of Australian postcodes that intersect NSW legislative districts:

Code
poa_shp <- poa_shp %>% 
  semi_join(nsw_addresses %>% distinct(postcode),
            by = c("POA_CODE21" = "postcode")) 
poa_shp_small <- poa_shp %>% 
  st_simplify(preserveTopology = TRUE, dTolerance = 5)

geojsonio::geojson_write(poa_shp_small,file = here("data/nsw_poa_shp.json"))

Counts of addresses by legislative district

We compute counts of addresses with postcodes within districts; we also compute

  • percentage of a district’s addresses in a given postcode (per_of_district)
  • percentage of a postcode’s addresses in a given district (per_of_postcode)
Code
out <- nsw_addresses %>% 
  count(district,postcode) %>% 
  group_by(district) %>% 
  mutate(per_of_district = n/sum(n)*100) %>% 
  ungroup() %>% 
  group_by(postcode) %>% 
  mutate(per_of_postcode = n/sum(n)*100) %>% 
  ungroup() %>% 
  arrange(district,desc(per_of_district))

rm(nsw_addresses)
Code
ojs_define(out_raw=out)

Linked table and map

Code
out = transpose(out_raw)
viewof theDistrict = Inputs.select(out.map(d => d.district),
    {
      label: "District: ",
      sort: true,
      unique: true
    }
  )
out_small = out.filter(d => d.district == theDistrict)
Code
Inputs.table(
  out_small,
  {
   format: {
    per_of_district: x => x.toFixed(1),
    per_of_postcode: x => x.toFixed(1)
  },
  rows: out_small.length + 10
  }
)
Code
nsw_shp_json = await FileAttachment("data/nsw_shp.json").json()
Code
width = 800
height = 650
poa_shp_json = FileAttachment("data/nsw_poa_shp.json").json()

thePostcodes = out_small.map(d => d.postcode)
Code
L = require('leaflet@1.9.2')

map2 = {
  let container = DOM.element ('div', { style: `width:${width}px;height:${height}px` });
  yield container;
  
  let map = L.map(container)
  let osmLayer = L.tileLayer('https://stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}{r}.{ext}', {
      attribution: 'Map tiles by <a href="http://stamen.com">Stamen Design</a>, <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a> &mdash; Map data &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
  subdomains: 'abcd',
    minZoom: 0,
    maxZoom: 25,
    ext: 'png'
  }).addTo(map);
  
  function districtFilter(feature) {
    if(feature.properties.districtna === theDistrict) return true
  }
  
  function postCodeFilter(feature) {
    if(thePostcodes.includes(feature.properties.POA_CODE21)) return true;
  }

  function style(feature) {
    return {
        fillColor: "blue",
        weight: 2,
        opacity: 0.7,
        color: 'blue',
        fillOpacity: 0.0
    };
  }

  // highlight function
  function highlightFeature(e) {
    var layer = e.target;

    layer.setStyle({
        fillOpacity: 0.5,
        opacity: 1.0,
        weight: 3
    });
    
    layer.openTooltip();
    
    layer.bringToFront();
  }

  // mouseout
  function resetHighlight(e) {
    poaLayer.resetStyle(e.target);
  }
  
  function onEachFeature(feature, layer) {
    layer.bindTooltip("<div style='background:white; padding:1px 3px 1px 3px'><b>" + feature.properties.POA_CODE21 + "</b></div>",
                     {
                        direction: 'right',
                        permanent: false,
                        sticky: true,
                        offset: [10, 0],
                        opacity: 1,
                        className: 'leaflet-tooltip-own'
                     });
    layer.on({
        mouseover: highlightFeature,
        mouseout: resetHighlight
    });
  }

  let DistLayer  = L.geoJson(nsw_shp_json, 
    {
      filter: districtFilter,
      weight: 5, 
      color: "#cc00007f",
    }).bindPopup(function (Layer) {
        return Layer.feature.properties.districtna;
    }).addTo(map);

  let poaLayer = L.geoJson(poa_shp_json,
   {
      filter: postCodeFilter,
      style : style,
      onEachFeature: onEachFeature
    })
    .addTo(map);

  map.fitBounds(DistLayer.getBounds());
}

Write to file

Code
write.csv(out,
          file = here("data/district_postcode_counts.csv"),
          row.names = FALSE)