mirror of
https://github.com/wavelog/wavelog.git
synced 2026-03-22 10:24:14 +00:00
457 lines
15 KiB
PHP
457 lines
15 KiB
PHP
<?php
|
|
|
|
if ( ! defined('BASEPATH')) exit('No direct script access allowed');
|
|
|
|
class Zonechecker extends CI_Controller {
|
|
private $geojsonFile = null;
|
|
private $geojsonData = null;
|
|
private $qralib;
|
|
private $gridsquareCache = array(); // Cache gridsquare->zone lookups
|
|
private $spatialIndex = null; // Spatial index for faster lookups
|
|
private $featureBoundingBoxes = array(); // Pre-calculated bounding boxes
|
|
|
|
function __construct() {
|
|
parent::__construct();
|
|
|
|
$this->load->model('user_model');
|
|
if(!$this->user_model->authorize(99)) { $this->session->set_flashdata('error', __("You're not allowed to do that!")); redirect('dashboard'); }
|
|
}
|
|
|
|
public function index() {
|
|
|
|
$this->load->model('stations');
|
|
|
|
$data['station_profile'] = $this->stations->all_of_user();
|
|
|
|
$data['page_title'] = __("Gridsquare Zone finder");
|
|
$this->load->view('interface_assets/header', $data);
|
|
$this->load->view('zonechecker/index');
|
|
$this->load->view('interface_assets/footer');
|
|
}
|
|
|
|
function getQsos($station_id) {
|
|
$sql = 'select distinct col_country, col_call, col_dxcc, col_time_on, station_profile.station_profile_name, col_primary_key, col_cqz, col_ituz, col_gridsquare
|
|
from ' . $this->config->item('table_name') . '
|
|
join station_profile on ' . $this->config->item('table_name') . '.station_id = station_profile.station_id
|
|
where station_profile.user_id = ?
|
|
and length(col_gridsquare) >= 6';
|
|
$params[] = array($this->session->userdata('user_id'));
|
|
|
|
if ($station_id && is_numeric($station_id)) {
|
|
$sql .= ' and ' . $this->config->item('table_name') . '.station_id = ?';
|
|
$params[] = $station_id;
|
|
}
|
|
|
|
$sql .= ' order by station_profile.station_profile_name asc, col_time_on desc';
|
|
|
|
$query = $this->db->query($sql, $params);
|
|
|
|
return $query;
|
|
}
|
|
|
|
|
|
function doWazCheck() {
|
|
set_time_limit(3600);
|
|
$de = $this->input->post('de', true);
|
|
$zoneType = $this->input->post('zoneType', true) ?: 'cq'; // Default to CQ if not specified
|
|
|
|
$i = 0;
|
|
$result = array();
|
|
$this->gridsquareCache = array(); // Reset cache
|
|
|
|
$callarray = $this->getQsos($de)->result();
|
|
|
|
// Starting clock time in seconds
|
|
$start_time = microtime(true);
|
|
|
|
// Load appropriate GeoJSON file based on zone type
|
|
if ($this->geojsonFile === null) {
|
|
if ($zoneType === 'itu') {
|
|
$this->geojsonFile = "assets/json/geojson/ituzones.geojson";
|
|
} else {
|
|
$this->geojsonFile = "assets/json/geojson/cqzones.geojson";
|
|
}
|
|
$this->geojsonData = $this->loadGeoJsonFile($this->geojsonFile);
|
|
}
|
|
|
|
if ($this->geojsonData === null) {
|
|
return null;
|
|
}
|
|
|
|
$hits = 0; // Track cache hits for performance metrics
|
|
$misses = 0; // Track cache misses
|
|
|
|
foreach ($callarray as $qso) {
|
|
$i++;
|
|
$gridsquare = $qso->col_gridsquare;
|
|
|
|
// Check cache first - avoid redundant gridsquare->zone conversions
|
|
if (!isset($this->gridsquareCache[$gridsquare])) {
|
|
$zone = $this->findCqZoneFromGridsquare($gridsquare, $zoneType);
|
|
$this->gridsquareCache[$gridsquare] = $zone;
|
|
$misses++;
|
|
} else {
|
|
$zone = $this->gridsquareCache[$gridsquare];
|
|
$hits++;
|
|
}
|
|
|
|
// Check zone based on type
|
|
if ($zoneType === 'itu') {
|
|
if (!isset($zone['itu_zone_number'])) {
|
|
continue;
|
|
}
|
|
|
|
if ($qso->col_ituz != $zone['itu_zone_number']) {
|
|
$result[] = [
|
|
'id' => $qso->col_primary_key,
|
|
'qso_date' => $qso->col_time_on,
|
|
'callsign' => $qso->col_call,
|
|
'station_profile' => $qso->station_profile_name,
|
|
'ituzone' => $qso->col_ituz,
|
|
'gridsquare' => $qso->col_gridsquare,
|
|
'itugeo' => $zone['itu_zone_number'],
|
|
'zone_type' => 'ITU',
|
|
];
|
|
}
|
|
} else {
|
|
// CQ Zone (default)
|
|
if (!isset($zone['cq_zone_number'])) {
|
|
continue;
|
|
}
|
|
|
|
if ($qso->col_cqz != $zone['cq_zone_number']) {
|
|
$result[] = [
|
|
'id' => $qso->col_primary_key,
|
|
'qso_date' => $qso->col_time_on,
|
|
'callsign' => $qso->col_call,
|
|
'station_profile' => $qso->station_profile_name,
|
|
'cqzone' => $qso->col_cqz,
|
|
'gridsquare' => $qso->col_gridsquare,
|
|
'cqgeo' => $zone['cq_zone_number'],
|
|
'zone_type' => 'CQ',
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
// End clock time in seconds
|
|
$end_time = microtime(true);
|
|
|
|
// Calculate script execution time
|
|
$execution_time = ($end_time - $start_time);
|
|
|
|
$data['execution_time'] = $execution_time;
|
|
$data['calls_tested'] = $i;
|
|
$data['cache_hits'] = $hits;
|
|
$data['cache_misses'] = $misses;
|
|
$data['cache_hit_rate'] = $i > 0 ? round(($hits / $i) * 100, 2) : 0;
|
|
$data['result'] = $result;
|
|
$data['zone_type'] = $zoneType;
|
|
|
|
$this->loadView($data);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Pre-process: calculate bounding boxes and build spatial index
|
|
$this->preProcessGeoJson($data);
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Pre-process GeoJSON to calculate bounding boxes and build spatial index
|
|
* This dramatically speeds up point-in-polygon lookups
|
|
*/
|
|
private function preProcessGeoJson(&$geojsonData) {
|
|
if (!isset($geojsonData['features']) || !is_array($geojsonData['features'])) {
|
|
return;
|
|
}
|
|
|
|
$this->featureBoundingBoxes = array();
|
|
$this->spatialIndex = array(
|
|
'minLng' => 180,
|
|
'maxLng' => -180,
|
|
'minLat' => 90,
|
|
'maxLat' => -90,
|
|
'grid' => array() // 10x10 degree grid for coarse filtering
|
|
);
|
|
|
|
foreach ($geojsonData['features'] as $index => &$feature) {
|
|
$bbox = $this->calculateFeatureBoundingBox($feature);
|
|
|
|
if ($bbox !== null) {
|
|
// Store bbox for quick access
|
|
$feature['bbox'] = $bbox;
|
|
$this->featureBoundingBoxes[$index] = $bbox;
|
|
|
|
// Update global bounds
|
|
$this->spatialIndex['minLng'] = min($this->spatialIndex['minLng'], $bbox[0]);
|
|
$this->spatialIndex['maxLng'] = max($this->spatialIndex['maxLng'], $bbox[2]);
|
|
$this->spatialIndex['minLat'] = min($this->spatialIndex['minLat'], $bbox[1]);
|
|
$this->spatialIndex['maxLat'] = max($this->spatialIndex['maxLat'], $bbox[3]);
|
|
|
|
// Add to spatial grid (10x10 degree cells)
|
|
$minGridLng = floor($bbox[0] / 10) * 10;
|
|
$maxGridLng = floor($bbox[2] / 10) * 10;
|
|
$minGridLat = floor($bbox[1] / 10) * 10;
|
|
$maxGridLat = floor($bbox[3] / 10) * 10;
|
|
|
|
for ($lng = $minGridLng; $lng <= $maxGridLng; $lng += 10) {
|
|
for ($lat = $minGridLat; $lat <= $maxGridLat; $lat += 10) {
|
|
$key = $lng . ',' . $lat;
|
|
if (!isset($this->spatialIndex['grid'][$key])) {
|
|
$this->spatialIndex['grid'][$key] = array();
|
|
}
|
|
$this->spatialIndex['grid'][$key][] = $index;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate bounding box for a feature
|
|
* @return array|null [minLng, minLat, maxLng, maxLat] or null
|
|
*/
|
|
private function calculateFeatureBoundingBox($feature) {
|
|
if (!isset($feature['geometry']['coordinates']) || !isset($feature['geometry']['type'])) {
|
|
return null;
|
|
}
|
|
|
|
$geometryType = $feature['geometry']['type'];
|
|
$coordinates = $feature['geometry']['coordinates'];
|
|
|
|
$minLng = 180;
|
|
$maxLng = -180;
|
|
$minLat = 90;
|
|
$maxLat = -90;
|
|
|
|
if ($geometryType === 'Polygon') {
|
|
$this->updateBoundsFromCoords($coordinates[0], $minLng, $minLat, $maxLng, $maxLat);
|
|
} elseif ($geometryType === 'MultiPolygon') {
|
|
foreach ($coordinates as $polygon) {
|
|
$this->updateBoundsFromCoords($polygon[0], $minLng, $minLat, $maxLng, $maxLat);
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
|
|
if ($minLng > $maxLng || $minLat > $maxLat) {
|
|
return null;
|
|
}
|
|
|
|
return array($minLng, $minLat, $maxLng, $maxLat);
|
|
}
|
|
|
|
/**
|
|
* Update min/max bounds from coordinate array
|
|
*/
|
|
private function updateBoundsFromCoords($coords, &$minLng, &$minLat, &$maxLng, &$maxLat) {
|
|
foreach ($coords as $point) {
|
|
$lng = $point[0];
|
|
$lat = $point[1];
|
|
$minLng = min($minLng, $lng);
|
|
$maxLng = max($maxLng, $lng);
|
|
$minLat = min($minLat, $lat);
|
|
$maxLat = max($maxLat, $lat);
|
|
}
|
|
}
|
|
|
|
public function findCqZoneFromGridsquare($gridsquare, $type) {
|
|
$coords = $this->gridsquareToLatLng($gridsquare);
|
|
|
|
if ($coords === null) {
|
|
return null;
|
|
}
|
|
|
|
return $this->findFeatureContainingPoint($coords['lat'], $coords['lng'], $this->geojsonData);
|
|
}
|
|
|
|
// ============================================================================
|
|
// GEOMETRIC ALGORITHMS - Point-in-polygon detection
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Check if a point (latitude, longitude) is inside a polygon
|
|
* Uses optimized 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 - optimized with minimal variable assignments
|
|
for ($i = 0, $j = $count - 1; $i < $count; $j = $i++) {
|
|
$xi = $polygon[$i][0];
|
|
$yi = $polygon[$i][1];
|
|
$xj = $polygon[$j][0];
|
|
$yj = $polygon[$j][1];
|
|
|
|
$intersect = (($yi > $lat) != ($yj > $lat))
|
|
&& ($lng < ($xj - $xi) * ($lat - $yi) / ($yj - $yi) + $xi);
|
|
|
|
$inside ^= $intersect;
|
|
}
|
|
|
|
return $inside;
|
|
}
|
|
|
|
/**
|
|
* Find which feature in a GeoJSON FeatureCollection contains a given point
|
|
* Optimized with spatial indexing and bounding box pre-checks
|
|
*
|
|
* @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;
|
|
}
|
|
|
|
// Early exit: check global bounds
|
|
if ($this->spatialIndex !== null) {
|
|
if ($lng < $this->spatialIndex['minLng'] || $lng > $this->spatialIndex['maxLng'] ||
|
|
$lat < $this->spatialIndex['minLat'] || $lat > $this->spatialIndex['maxLat']) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Use spatial index to get candidate features
|
|
$candidateIndices = $this->getCandidateFeatures($lat, $lng);
|
|
|
|
// If no spatial index, fall back to checking all features
|
|
if ($candidateIndices === null) {
|
|
$candidateIndices = array_keys($geojsonData['features']);
|
|
}
|
|
|
|
// Check only candidate features
|
|
foreach ($candidateIndices as $index) {
|
|
if (!isset($geojsonData['features'][$index])) {
|
|
continue;
|
|
}
|
|
|
|
$feature = $geojsonData['features'][$index];
|
|
|
|
if (!isset($feature['geometry']['coordinates']) || !isset($feature['geometry']['type'])) {
|
|
continue;
|
|
}
|
|
|
|
// Fast bounding box check (always available now due to pre-processing)
|
|
if (isset($feature['bbox'])) {
|
|
$bbox = $feature['bbox'];
|
|
if ($lng < $bbox[0] || $lng > $bbox[2] || $lat < $bbox[1] || $lat > $bbox[3]) {
|
|
continue; // Point is outside bounding box, skip detailed check
|
|
}
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* Get candidate features that might contain the point using spatial index
|
|
* @return array|null Array of feature indices or null if index not available
|
|
*/
|
|
private function getCandidateFeatures($lat, $lng) {
|
|
if ($this->spatialIndex === null || !isset($this->spatialIndex['grid'])) {
|
|
return null;
|
|
}
|
|
|
|
// Find which grid cell the point falls into
|
|
$gridLng = floor($lng / 10) * 10;
|
|
$gridLat = floor($lat / 10) * 10;
|
|
$key = $gridLng . ',' . $gridLat;
|
|
|
|
if (isset($this->spatialIndex['grid'][$key])) {
|
|
return $this->spatialIndex['grid'][$key];
|
|
}
|
|
|
|
// If not found in grid, return null (fallback to all features)
|
|
return null;
|
|
}
|
|
|
|
public function gridsquareToLatLng($gridsquare) {
|
|
if (!$this->qralib) {
|
|
$this->load->library('Qra');
|
|
$this->qralib = $this->qra;
|
|
}
|
|
|
|
if (!is_string($gridsquare) || strlen($gridsquare) < 2) {
|
|
return null;
|
|
}
|
|
|
|
$result = $this->qralib->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]
|
|
];
|
|
}
|
|
|
|
function loadView($data) {
|
|
$this->load->view('zonechecker/result', $data);
|
|
}
|
|
|
|
|
|
}
|