Chapter sebelumnya memantau pergeseran antar waktu; chapter ini berpindah ke dimensi yang selama ini kita abaikan: ruang. Angka penjualan per kedai bisa disusun dari yang tertinggi ke terendah, tetapi pertanyaan bisnis yang sesungguhnya berbunyi “di mana wilayah yang menjanjikan untuk cabang baru?” — dan pertanyaan itu hanya bisa dijawab kalau setiap kedai kita tempatkan ke dalam wilayah administratifnya. Kita sudah mengenal bentuk data geospasial dan CRS pada chapter Pengambilan Data Geospasial , lalu mewarnainya sebagai choropleth pada chapter Visualisasi Geospasial . Sekarang kita naik ke pekerjaan analisis sungguhan: join spasial untuk menempatkan titik ke dalam poligon, agregasi per wilayah, dan normalisasi angka dengan luas supaya perbandingan antar wilayah adil.

Menyiapkan Dua Lapis Data #

Analisis geospasial klasik bekerja dengan dua lapis: lapis wilayah (poligon kecamatan pada berkas GeoJSON) dan lapis kejadian (titik pada tabel biasa). Kita pakai empat kecamatan fiktif di sekitar Bandung dan enam belas kedai mitra Toko Kopi Kita beserta penjualan bulanannya (dalam ribuan rupiah):

csv
kedai,bujur,lintang,penjualan_ribu
Kopi Rimbun,107.600,-6.910,8200
Kopi Sejuk,107.615,-6.905,7400
Warung Kopi Mbaro,107.600,-6.905,6100
Kopi Hutan,107.610,-6.915,5300
Kopi Kita Cabang Sukajadi,107.630,-6.915,13100
Kopi Senja,107.640,-6.905,9800
Kopi Loka,107.625,-6.910,8900
Kopi Pagi,107.645,-6.910,7600
Kopi Teras,107.635,-6.895,7000
Kopi Pasar,107.600,-6.890,4500
Kopi Stasiun,107.615,-6.885,5200
Kopi Jaya,107.595,-6.895,3800
Kopi Dago,107.640,-6.885,11200
Kopi Taman,107.630,-6.890,9900
Kopi Ujung,107.670,-6.900,6600
Kopi Selatan,107.600,-6.950,4100
json
{
  "type": "FeatureCollection",
  "name": "kecamatan",
  "features": [
    {
      "type": "Feature",
      "properties": {"nama": "Sukasari"},
      "geometry": {"type": "Polygon", "coordinates": [[[107.58, -6.93], [107.62, -6.93], [107.62, -6.90], [107.58, -6.90], [107.58, -6.93]]]}
    },
    {
      "type": "Feature",
      "properties": {"nama": "Sukajadi"},
      "geometry": {"type": "Polygon", "coordinates": [[[107.62, -6.92], [107.65, -6.92], [107.65, -6.90], [107.62, -6.90], [107.62, -6.92]]]}
    },
    {
      "type": "Feature",
      "properties": {"nama": "Andir"},
      "geometry": {"type": "Polygon", "coordinates": [[[107.58, -6.90], [107.62, -6.90], [107.62, -6.88], [107.58, -6.88], [107.58, -6.90]]]}
    },
    {
      "type": "Feature",
      "properties": {"nama": "Coblong"},
      "geometry": {"type": "Polygon", "coordinates": [[[107.62, -6.90], [107.65, -6.90], [107.65, -6.88], [107.62, -6.88], [107.62, -6.90]]]}
    }
  ]
}

Tabel kedai berbentuk DataFrame pandas biasa; supaya bisa diajak bicara secara geografis, kita angkat menjadi GeoDataFrame dengan kolom geometri titik yang dibangun dari pasangan bujur-lintang:

python
import geopandas as gpd
import pandas as pd

gdf_kecamatan = gpd.read_file("kecamatan.geojson")
df_kedai = pd.read_csv("kedai_mitra.csv")

gdf_kedai = gpd.GeoDataFrame(
    df_kedai,
    geometry=gpd.points_from_xy(df_kedai["bujur"], df_kedai["lintang"]),
    crs="EPSG:4326",
)

print("Kecamatan   :", ", ".join(gdf_kecamatan["nama"]))
print("Jumlah kedai:", len(gdf_kedai))
print("CRS poligon :", gdf_kecamatan.crs.to_epsg(), "| CRS titik:", gdf_kedai.crs.to_epsg())
print(gdf_kedai[["kedai", "bujur", "lintang", "penjualan_ribu"]].head(4).to_string(index=False))

Output yang Dihasilkan:

text
Kecamatan   : Sukasari, Sukajadi, Andir, Coblong
Jumlah kedai: 16
CRS poligon : 4326 | CRS titik: 4326
            kedai   bujur  lintang  penjualan_ribu
      Kopi Rimbun 107.600   -6.910            8200
       Kopi Sejuk 107.615   -6.905            7400
Warung Kopi Mbaro 107.600   -6.905            6100
       Kopi Hutan 107.610   -6.915            5300

Bedah baris pentingnya:

  1. Baris 8 (gpd.points_from_xy): membaca kolom bujur dulu baru lintang — urutan ini wajib sesuai konvensi [bujur, lintang] yang sudah kita bahas di chapter Pengambilan Data Geospasial; tertukar urutannya berarti semua titik mendarat di lokasi yang salah tanpa pesan galat.
  2. Baris 6-10 (membungkus jadi GeoDataFrame): gpd.GeoDataFrame menempelkan kolom geometry pada DataFrame biasa; mulai titik ini, kedai bisa diperlakukan sebagai objek spasial.
  3. crs="EPSG:4326": koordinat kedai dinyatakan dalam sistem yang sama dengan poligon kecamatan — satu syarat yang akan kita lihat kenapa penting pada join berikutnya.

Join Spasial: Menempatkan Titik ke dalam Poligon #

Pertanyaan “kedai ini milik kecamatan mana?” secara tabel tidak bisa dijawab — tidak ada kolom penghubung. Jawabannya ada pada geometri: poligon yang memuat sebuah titik. gpd.sjoin menjalankan pencocokan itu untuk semua titik sekaligus, persis seperti merge tetapi dengan kunci geometri alih-alih kolom nilai:

python
import geopandas as gpd
import pandas as pd

gdf_kecamatan = gpd.read_file("kecamatan.geojson")
df_kedai = pd.read_csv("kedai_mitra.csv")
gdf_kedai = gpd.GeoDataFrame(
    df_kedai,
    geometry=gpd.points_from_xy(df_kedai["bujur"], df_kedai["lintang"]),
    crs="EPSG:4326",
)

gabung = gpd.sjoin(gdf_kedai, gdf_kecamatan, how="inner", predicate="within")

print(f"Kedai sebelum join  : {len(gdf_kedai)}")
print(f"Kedai di dalam poligon : {len(gabung)}")
di_luar = gdf_kedai.loc[~gdf_kedai.index.isin(gabung.index), "kedai"]
print("Kedai di luar wilayah:", ", ".join(di_luar))
print(gabung[["kedai", "penjualan_ribu", "nama"]].head(6).to_string(index=False))

Output yang Dihasilkan:

text
Kedai sebelum join  : 16
Kedai di dalam poligon : 14
Kedai di luar wilayah: Kopi Ujung, Kopi Selatan
                    kedai  penjualan_ribu     nama
              Kopi Rimbun            8200 Sukasari
               Kopi Sejuk            7400 Sukasari
        Warung Kopi Mbaro            6100 Sukasari
               Kopi Hutan            5300 Sukasari
Kopi Kita Cabang Sukajadi           13100 Sukajadi
               Kopi Senja            9800 Sukajadi

Mari kita telaah pemakaiannya:

  1. gpd.sjoin(titik, poligon, predicate="within"): setiap titik dicocokkan ke poligon yang memuatnya; kata kunci within berarti “geometri kiri berada di dalam geometri kanan”. Hasilnya satu baris per pasangan titik-poligon, dengan kolom nama kecamatan menempel di samping data kedai.
  2. how="inner": kedai yang tidak berada di dalam poligon mana pun dikeluarkan dari hasil — di sini dua kedai, “Kopi Ujung” di luar batas timur dan “Kopi Selatan” di luar batas selatan. Gunakan how="left" bila baris itu tetap perlu dipertahankan (misalnya untuk melaporkan kedai lepasoperasi).
  3. Pembanding indeks untuk menemukan yang tercecer: ~gdf_kedai.index.isin(gabung.index) membalik keanggotaan indeks — pola yang sama dengan menyaring baris di pandas, kali ini dipakai untuk mengaudit sisi hilang dari join.

Agregasi per Wilayah dan Normalisasi Luas #

Begitu setiap kedai membawa nama kecamatannya, pertanyaan “berapa total penjualan per wilayah” kembali menjadi soal groupby biasa — persis pola agregasi tabular yang kita kenal sejak section dasar-data. Yang geospasial tambahkan adalah langkah berikutnya: menormalkan angka itu dengan luas wilayah, karena membandingkan total mentah antar kecamatan yang luasnya berbeda sama tidak adilnya dengan membandingkan total penjualan toko yang buka 30 hari lawan yang buka 7 hari:

python
import geopandas as gpd
import pandas as pd

gdf_kecamatan = gpd.read_file("kecamatan.geojson")
df_kedai = pd.read_csv("kedai_mitra.csv")
gdf_kedai = gpd.GeoDataFrame(
    df_kedai,
    geometry=gpd.points_from_xy(df_kedai["bujur"], df_kedai["lintang"]),
    crs="EPSG:4326",
)
gabung = gpd.sjoin(gdf_kedai, gdf_kecamatan, how="inner", predicate="within")

rekap = gabung.groupby("nama").agg(
    jumlah_kedai=("kedai", "count"),
    total_penjualan=("penjualan_ribu", "sum"),
    rata_penjualan=("penjualan_ribu", "mean"),
).round(0)
print(rekap.to_string())

peta = gdf_kecamatan.merge(rekap, on="nama", how="left")
peta_km = peta.to_crs(32748)
peta_km["luas_km2"] = (peta_km.area / 1e6).round(2)
peta_km["penjualan_per_km2"] = (peta_km["total_penjualan"] / peta_km["luas_km2"]).round(0)
print()
print(peta_km[["nama", "luas_km2", "jumlah_kedai", "total_penjualan", "penjualan_per_km2"]].to_string(index=False))

Output yang Dihasilkan:

text
          jumlah_kedai  total_penjualan  rata_penjualan
nama                                                   
Andir                3            13500          4500.0
Coblong              3            28100          9367.0
Sukajadi             4            39400          9850.0
Sukasari             4            27000          6750.0

    nama  luas_km2  jumlah_kedai  total_penjualan  penjualan_per_km2
Sukasari     14.68             4            27000             1839.0
Sukajadi      7.34             4            39400             5368.0
   Andir      9.79             3            13500             1379.0
 Coblong      7.34             3            28100             3828.0

Bedah langkah-langkahnya:

  1. groupby("nama").agg(...): agregasi berjalan pada hasil join yang berbentuk tabel — tidak ada yang geografis di sini, dan memang begitulah seharusnya: begitu join selesai, seluruh peralatan pandas kembali tersedia.
  2. gdf_kecamatan.merge(rekap, on="nama"): rekap tiga kolom ditempelkan kembali ke poligon supaya siap dipetakan atau diolah lebih lanjut; how="left" menjaga kecamatan tanpa kedai tetap tampil (dengan nilai kosong yang perlu ditangani).
  3. to_crs(32748) lalu .area / 1e6: luas dihitung dalam proyeksi UTM bersatuan meter, lalu dibagi sejuta menjadi km² — persis pelajaran dari chapter Pengambilan Data Geospasial bahwa derajat bukan satuan luas. Sukasari tercatat 14,68 km², hampir dua kali Coblong yang 7,34 km².
  4. penjualan_per_km2: total penjualan dibagi luas; inilah angka yang layak dibandingkan antar wilayah.

Membaca Pola Spasial #

Tabel akhirnya bercerita. Sukajadi memimpin di semua ukuran — total 39.400, kepadatan 5.368 per km², bahkan rata-rata per kedai (9.850) — sedangkan Andir tertinggal di posisi bawah (1.379 per km²). Selisih kepadatan keempat wilayah hampir empat kali lipat, dan polanya bergerak dari timur laut (Sukajadi, Coblong) yang padat ke barat daya (Sukasari, Andir) yang longgar: pola spasial yang tidak akan pernah terlihat dari tabel kedai yang diurutkan menurut penjualan.

Kasus paling instruktif justru pasangan Sukasari-Coblong. Pada total mentah keduanya tampak berimbang — 27.000 lawan 28.100, selisih 4% — namun Sukasari menyediakan luas hampir dua kali Coblong, sehingga kepadatan penjualannya hanya 1.839 berbanding 3.828 per km². Rapat ekspansi yang hanya membaca total mentah bisa menyimpulkan kedua wilayah sama menjanjikan; setelah dinormalkan, Coblong jelas lebih intensif. Sebaliknya, “jumlah kedai” yang sama-sama kecil (3-4) mengingatkan kita bahwa setiap angka wilayah di sini ditopang sedikit kedai — rata-rata 9.367 di Coblong bergantung besar pada dua kedai besar, jadi keputusan final layak menunggu data lebih banyak.

Rangkuman #

  • Analisis geospasial bekerja dengan dua lapis: poligon wilayah (GeoJSON) dan titik kejadian (CSV berkoordinat) yang diangkat menjadi GeoDataFrame lewat points_from_xy.
  • gpd.sjoin dengan predicate="within" adalah merge versi geografis: menempelkan atribut wilayah pada setiap titik yang berada di dalamnya; how="inner" menyisihkan titik yang berada di luar semua poligon.
  • Setelah join, agregasi per wilayah kembali menjadi groupby biasa; hasilnya ditempelkan ke poligon dengan merge untuk analisis atau pemetaan lanjutan.
  • Luas dihitung setelah to_crs ke proyeksi meter, dan perbandingan antar wilayah layak hanya setelah dinormalkan per km² — Sukasari dan Coblong yang berimbang di total mentah terpisah dua kali lipat setelah dinormalkan.
  • Pola spasial mengarahkan pertanyaan “di mana”, tetapi jawaban “mengapa” tetap butuh penalaran kausal.

Geospasial memperluas data kita dengan dimensi “di mana”. Ada satu jenis data yang bahkan lebih melimpah di dunia nyata dan sama-sama tidak berbentuk tabel rapi: teks — ulasan pelanggan, pesan masuk, keluhan di media sosial. Pada chapter berikutnya kita belajar mengubah teks bebas menjadi fitur numerik lalu mengklasifikasikannya: selamat datang di Natural Language Processing .

Referensi: