Files
wavelog/application/libraries/Geojson.php
2025-12-07 19:52:26 +01:00

298 lines
12 KiB
PHP

<?php if (! defined('BASEPATH')) exit('No direct script access allowed');
/**
* Geojson Library
*
* This library provides GeoJSON-based geographic operations for Wavelog,
* used for determining states, provinces, and other administrative subdivisions
* from gridsquare locators using point-in-polygon detection.
*
* Main functionality:
* - Convert Maidenhead gridsquares to lat/lng coordinates
* - Determine state/province from coordinates using GeoJSON boundary data
* - Point-in-polygon detection for Polygon and MultiPolygon geometries
*
*/
class Geojson {
/**
* DXCC entities that support state/province subdivision lookups
*
* Key: DXCC number
* Value: Array with 'name' and 'enabled' flag
*/
const SUPPORTED_STATES = [
1 => ['name' => 'Canada', 'enabled' => true], // 13 provinces/territories
6 => ['name' => 'Alaska', 'enabled' => true], // 1 state
27 => ['name' => 'Belarus', 'enabled' => true], // 7 subdivisions
29 => ['name' => 'Canary Islands', 'enabled' => true], // 2 provinces
32 => ['name' => 'Ceuta & Melilla', 'enabled' => true], // 2 autonomous cities
50 => ['name' => 'Mexico', 'enabled' => true], // 32 states
100 => ['name' => 'Argentina', 'enabled' => true], // 24 subdivisions
108 => ['name' => 'Brazil', 'enabled' => true], // 27 subdivisions
112 => ['name' => 'Chile', 'enabled' => true], // 16 regions
137 => ['name' => 'Republic of Korea', 'enabled' => true], // 17 subdivisions
110 => ['name' => 'Hawaii', 'enabled' => true], // 1 state
144 => ['name' => 'Uruguay', 'enabled' => true], // 19 subdivisions
148 => ['name' => 'Venezuela', 'enabled' => true], // 24 states
149 => ['name' => 'Azores', 'enabled' => true], // 1 autonomous region
150 => ['name' => 'Australia', 'enabled' => true], // 8 subdivisions
163 => ['name' => 'Papua New Guinea', 'enabled' => true], // 22 provinces
170 => ['name' => 'New Zealand', 'enabled' => true], // 16 regions
209 => ['name' => 'Belgium', 'enabled' => true], // 11 subdivisions
212 => ['name' => 'Bulgaria', 'enabled' => true], // 28 subdivisions
214 => ['name' => 'Corsica', 'enabled' => true], // 2 departments (2A, 2B)
225 => ['name' => 'Sardinia', 'enabled' => true], // 5 provinces
227 => ['name' => 'France', 'enabled' => true], // 96 departments
230 => ['name' => 'Germany', 'enabled' => true], // 16 federal states
239 => ['name' => 'Hungary', 'enabled' => true], // 20 subdivisions
245 => ['name' => 'Ireland', 'enabled' => true], // 27 subdivisions
248 => ['name' => 'Italy', 'enabled' => true], // 107 provinces
256 => ['name' => 'Madeira Islands', 'enabled' => true], // 1 autonomous region
263 => ['name' => 'Netherlands', 'enabled' => true], // 12 provinces
266 => ['name' => 'Norway', 'enabled' => true], // 11 counties
269 => ['name' => 'Poland', 'enabled' => true], // 16 voivodeships
272 => ['name' => 'Portugal', 'enabled' => true], // 18 districts
275 => ['name' => 'Romania', 'enabled' => true], // 42 counties
281 => ['name' => 'Spain', 'enabled' => true], // 47 provinces
284 => ['name' => 'Sweden', 'enabled' => true], // 21 subdivisions
287 => ['name' => 'Switzerland', 'enabled' => true], // 26 cantons
291 => ['name' => 'USA', 'enabled' => true], // 52 states/territories
318 => ['name' => 'China', 'enabled' => true], // 31 provinces
324 => ['name' => 'India', 'enabled' => true], // 36 states/territories
339 => ['name' => 'Japan', 'enabled' => true], // 47 prefectures
386 => ['name' => 'Taiwan', 'enabled' => true], // 22 subdivisions
497 => ['name' => 'Croatia', 'enabled' => true], // 21 subdivisions
];
private $qra;
public function __construct() {
$CI =& get_instance();
$CI->load->library('qra');
$this->qra = $CI->qra;
}
// ============================================================================
// PUBLIC API METHODS - Main entry points for state lookup
// ============================================================================
/**
* Find state from grid square locator
*
* This is the main method used by the application to determine state/province
* from a Maidenhead gridsquare.
*
* @param string $gridsquare Maidenhead grid square (e.g., "FM18lw")
* @param int $dxcc DXCC entity number (e.g., 291 for USA)
* @return array|null State properties (including 'code' and 'name') or null if not found
*/
public function findStateFromGridsquare($gridsquare, $dxcc) {
$coords = $this->gridsquareToLatLng($gridsquare);
if ($coords === null) {
return null;
}
return $this->findStateByDxcc($coords['lat'], $coords['lng'], $dxcc);
}
/**
* Find state by DXCC entity number and coordinates
*
* This method loads the appropriate GeoJSON file for the DXCC entity
* and searches for the state/province containing the given coordinates.
*
* @param float $lat Latitude
* @param float $lng Longitude
* @param int $dxcc DXCC entity number (e.g., 291 for USA)
* @return array|null State properties or null if not found
*/
public function findStateByDxcc($lat, $lng, $dxcc) {
// Check if state lookup is supported for this DXCC
if (!$this->isStateSupported($dxcc)) {
return null;
}
$geojsonFile = "assets/json/geojson/states_{$dxcc}.geojson";
$geojsonData = $this->loadGeoJsonFile($geojsonFile);
if ($geojsonData === null) {
return null;
}
return $this->findFeatureContainingPoint($lat, $lng, $geojsonData);
}
/**
* Check if state lookup is supported for given DXCC entity
*
* @param int $dxcc DXCC entity number
* @return bool True if state lookup is supported and enabled
*/
public function isStateSupported($dxcc) {
return isset(self::SUPPORTED_STATES[$dxcc]) && self::SUPPORTED_STATES[$dxcc]['enabled'] === true;
}
/**
* Retrieve list of DXCC entities that support state/province lookups
*
* @return array List of supported DXCC entities
*/
public function getSupportedDxccs() {
return self::SUPPORTED_STATES;
}
// ============================================================================
// COORDINATE CONVERSION
// ============================================================================
/**
* Convert Maidenhead grid square to latitude/longitude
*
* Uses the Qra library for gridsquare conversion.
* Supports 2, 4, 6, 8, and 10 character gridsquares.
* Also supports grid lines and grid corners (comma-separated).
*
* @param string $gridsquare Maidenhead grid square (e.g., "JO70va")
* @return array|null Array with 'lat' and 'lng' or null on error
*/
public function gridsquareToLatLng($gridsquare) {
if (!is_string($gridsquare) || strlen($gridsquare) < 2) {
return null;
}
$result = $this->qra->qra2latlong($gridsquare);
if ($result === false || !is_array($result) || count($result) < 2) {
return null;
}
// Qra library returns [lat, lng], we need to return associative array
return [
'lat' => $result[0],
'lng' => $result[1]
];
}
// ============================================================================
// GEOJSON FILE OPERATIONS
// ============================================================================
/**
* Load and parse a GeoJSON file
*
* @param string $filepath Path to GeoJSON file (relative to FCPATH)
* @return array|null Decoded GeoJSON data or null on error
*/
public function loadGeoJsonFile($filepath) {
$fullpath = FCPATH . $filepath;
if (!file_exists($fullpath)) {
return null;
}
$geojsonData = file_get_contents($fullpath);
if ($geojsonData === false) {
return null;
}
// Remove BOM if present (UTF-8, UTF-16, UTF-32)
$geojsonData = preg_replace('/^\xEF\xBB\xBF|\xFF\xFE|\xFE\xFF|\x00\x00\xFE\xFF|\xFF\xFE\x00\x00/', '', $geojsonData);
// Additional cleanup: trim whitespace
$geojsonData = trim($geojsonData);
$data = json_decode($geojsonData, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return null;
}
return $data;
}
// ============================================================================
// GEOMETRIC ALGORITHMS - Point-in-polygon detection
// ============================================================================
/**
* Check if a point (latitude, longitude) is inside a polygon
* Uses ray casting algorithm
*
* @param float $lat Latitude of the point
* @param float $lng Longitude of the point
* @param array $polygon GeoJSON polygon coordinates array [[[lng, lat], [lng, lat], ...]]
* @return bool True if point is inside polygon, false otherwise
*/
public function isPointInPolygon($lat, $lng, $polygon) {
if (!is_numeric($lat) || !is_numeric($lng) || !is_array($polygon) || empty($polygon)) {
return false;
}
$inside = false;
$count = count($polygon);
// Ray casting algorithm
for ($i = 0, $j = $count - 1; $i < $count; $j = $i++) {
$xi = $polygon[$i][0]; // longitude
$yi = $polygon[$i][1]; // latitude
$xj = $polygon[$j][0]; // longitude
$yj = $polygon[$j][1]; // latitude
$intersect = (($yi > $lat) !== ($yj > $lat))
&& ($lng < ($xj - $xi) * ($lat - $yi) / ($yj - $yi) + $xi);
if ($intersect) {
$inside = !$inside;
}
}
return $inside;
}
/**
* Find which feature in a GeoJSON FeatureCollection contains a given point
*
* @param float $lat Latitude of the point
* @param float $lng Longitude of the point
* @param array $geojsonData Decoded GeoJSON FeatureCollection
* @return array|null Feature properties if found, null otherwise
*/
public function findFeatureContainingPoint($lat, $lng, $geojsonData) {
if (!isset($geojsonData['features']) || !is_array($geojsonData['features'])) {
return null;
}
foreach ($geojsonData['features'] as $feature) {
if (!isset($feature['geometry']['coordinates']) || !isset($feature['geometry']['type'])) {
continue;
}
$geometryType = $feature['geometry']['type'];
$coordinates = $feature['geometry']['coordinates'];
// Handle Polygon geometry
if ($geometryType === 'Polygon') {
// For Polygon, coordinates[0] is the outer ring
if ($this->isPointInPolygon($lat, $lng, $coordinates[0])) {
return $feature['properties'];
}
}
// Handle MultiPolygon geometry
elseif ($geometryType === 'MultiPolygon') {
foreach ($coordinates as $polygon) {
// For MultiPolygon, each polygon is [[[lng,lat],...]]
// We need to pass just the outer ring (first element)
if ($this->isPointInPolygon($lat, $lng, $polygon[0])) {
return $feature['properties'];
}
}
}
}
return null;
}
}