list_lookup.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. <?php
  2. // list_lookup.php (with PID cache)
  3. // --------------------------------------------------------------
  4. ini_set('display_errors','0'); error_reporting(E_ALL);
  5. set_error_handler(function($s,$m,$f,$l){ throw new ErrorException($m,0,$s,$f,$l); });
  6. header('Content-Type: application/json; charset=utf-8');
  7. define('LL_THROTTLE_US', 150000); // 150ms per request; set 0 to disable
  8. // Convert PHP notices/warnings into exceptions -> JSON error
  9. set_error_handler(function($severity, $message, $file, $line) {
  10. throw new ErrorException($message, 0, $severity, $file, $line);
  11. });
  12. ob_start();
  13. try {
  14. $raw = file_get_contents('php://input');
  15. $js = json_decode($raw, true);
  16. if (!$js || !isset($js['lat'],$js['lng'])) {
  17. http_response_code(400);
  18. echo json_encode(['ok'=>false,'error'=>'Missing lat/lng']); exit;
  19. }
  20. $lat = (float)$js['lat'];
  21. $lng = (float)$js['lng'];
  22. $debug = !empty($js['debug']);
  23. $full = !empty($js['full']) || !empty($js['debug']);
  24. // --- Small file cache ----------------------------------------------------
  25. define('LL_CACHE_DIR', __DIR__ . '/cache'); // adjust if you like
  26. define('LL_CACHE_TTL', 60 * 60 * 24 * 14); // 14 days
  27. function ll_cache_get($key){
  28. if (!is_dir(LL_CACHE_DIR)) return null;
  29. $f = LL_CACHE_DIR.'/'.preg_replace('/[^a-z0-9_.-]/i','_', $key).'.json';
  30. if (!is_file($f)) return null;
  31. if (filemtime($f) < time()-LL_CACHE_TTL) return null;
  32. $s = file_get_contents($f);
  33. $j = json_decode($s,true);
  34. return $j ?: null;
  35. }
  36. function ll_cache_set($key,$data){
  37. if (!is_dir(LL_CACHE_DIR)) @mkdir(LL_CACHE_DIR,0775,true);
  38. $f = LL_CACHE_DIR.'/'.preg_replace('/[^a-z0-9_.-]/i','_', $key).'.json';
  39. @file_put_contents($f, json_encode($data, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE));
  40. }
  41. // Pre-cache key by snapped lat/lng (so we can serve before we know PID)
  42. $snap = sprintf('%0.6f,%0.6f', $lat, $lng);
  43. $preKey = "v2-latlng-".$snap;
  44. if ($cached = ll_cache_get($preKey)) {
  45. if (!$full) unset($cached['raw']);
  46. echo json_encode($cached, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
  47. exit;
  48. }
  49. // ---------------- Config ----------------
  50. $service = 'https://services.thelist.tas.gov.au/arcgis/rest/services/Public/PlanningOnline/MapServer';
  51. // ---------------- Helpers ----------------
  52. function arcgis_query($layerId, $lng, $lat, $outFields='*', $returnGeometry=false, $extraParams=[]) {
  53. global $service;
  54. $params = array_merge([
  55. 'f' => 'json',
  56. 'where' => '1=1',
  57. 'returnGeometry' => $returnGeometry ? 'true' : 'false',
  58. 'outFields' => $outFields,
  59. 'outSR' => '4326',
  60. 'geometryType' => 'esriGeometryPoint',
  61. 'spatialRel' => 'esriSpatialRelIntersects',
  62. 'inSR' => '4326',
  63. 'geometry' => json_encode(['x'=>(float)$lng, 'y'=>(float)$lat]),
  64. 'resultRecordCount'=> 200
  65. ], $extraParams);
  66. $url = "{$service}/{$layerId}/query";
  67. $ch = curl_init($url);
  68. curl_setopt_array($ch, [
  69. CURLOPT_POST => true,
  70. CURLOPT_POSTFIELDS => http_build_query($params),
  71. CURLOPT_RETURNTRANSFER => true,
  72. CURLOPT_TIMEOUT => 12,
  73. ]);
  74. $resp = curl_exec($ch);
  75. if ($resp === false) { $err = curl_error($ch); curl_close($ch); return ['error'=>"cURL error: $err"]; }
  76. curl_close($ch);
  77. if (LL_THROTTLE_US > 0) usleep(LL_THROTTLE_US);
  78. return json_decode($resp, true);
  79. }
  80. // --- Add this helper block ---
  81. function wmProject($lon, $lat) { // Web Mercator meters
  82. // clamp latitude to valid mercator range
  83. if ($lat > 85.05112878) $lat = 85.05112878;
  84. if ($lat < -85.05112878) $lat = -85.05112878;
  85. $originShift = 20037508.342789244;
  86. $x = $lon * $originShift / 180.0;
  87. $y = log(tan((90 + $lat) * M_PI / 360.0)) * $originShift / M_PI;
  88. return [$x, $y];
  89. }
  90. function ringAreaSignedSqm(array $ring) { // ring is [[lon,lat],...]
  91. $n = count($ring); if ($n < 3) return 0.0;
  92. $sum = 0.0;
  93. for ($i=0; $i<$n; $i++) {
  94. $j = ($i+1) % $n;
  95. [$xi,$yi] = wmProject($ring[$i][0], $ring[$i][1]);
  96. [$xj,$yj] = wmProject($ring[$j][0], $ring[$j][1]);
  97. $sum += ($xi * $yj) - ($xj * $yi);
  98. }
  99. return 0.5 * $sum; // signed
  100. }
  101. function polygonAreaFromRingsSqm(array $rings) {
  102. // Esri rings: outers vs holes by winding; summing signed areas handles holes
  103. $total = 0.0;
  104. foreach ($rings as $ring) $total += ringAreaSignedSqm($ring);
  105. return abs($total);
  106. }
  107. // ---------------- Layer IDs ----------------
  108. $LAYER_PARCELS = 2; // Cadastral Parcels
  109. $LAYER_LGA = 8; // Local Government Areas
  110. $LAYER_ZONES = 13; // Tasm. Planning Scheme Zones
  111. $LAYER_CODES = 14; // Tasm. Planning Scheme Code Overlays
  112. // ---------------- Parcels (attributes) ----------------
  113. $parcelsResp = arcgis_query($LAYER_PARCELS, $lng, $lat, '*', false);
  114. $parcelFeat = ($parcelsResp['features'][0] ?? null);
  115. $parcelAttr = $parcelFeat['attributes'] ?? [];
  116. // PID (prefer numeric)
  117. $pid = null;
  118. foreach (['PID','PROPERTY_ID'] as $k) {
  119. if (!empty($parcelAttr[$k])) { $pid = preg_replace('/[^0-9]/','', (string)$parcelAttr[$k]); break; }
  120. }
  121. // ---------------- Parcel geometry (polygon) ----------------
  122. $parcelGeomResp = arcgis_query($LAYER_PARCELS, $lng, $lat, 'PID,OBJECTID', true);
  123. $boundaryGeoJSON = null;
  124. if (!empty($parcelGeomResp['features'][0]['geometry']['rings'])) {
  125. $rings = $parcelGeomResp['features'][0]['geometry']['rings']; // already [lng,lat] due to outSR=4326
  126. $boundaryGeoJSON = [
  127. 'type' => 'Feature',
  128. 'geometry' => [ 'type'=>'Polygon', 'coordinates'=>$rings ],
  129. 'properties' => [ 'pid'=>$pid ]
  130. ];
  131. }
  132. // ---------------- Title (Volume/Folio) & Area ----------------
  133. $volume = $parcelAttr['VOLUME'] ?? null;
  134. $folio = isset($parcelAttr['FOLIO']) ? (string)$parcelAttr['FOLIO'] : null;
  135. $titleId = ($volume && $folio !== null && $folio !== '') ? ($volume . '/' . $folio)
  136. : ($parcelAttr['CT'] ?? $parcelAttr['CT_REFERENCE'] ?? null);
  137. // Area: prefer measured area, then computed
  138. $sqm = null;
  139. foreach (['MEAS_AREA','COMP_AREA','AREA_SQM','Shape__Area','SHAPE_Area'] as $k) {
  140. if (isset($parcelAttr[$k]) && is_numeric($parcelAttr[$k])) { $sqm = (float)$parcelAttr[$k]; break; }
  141. }
  142. // Fallback: compute from geometry rings if available
  143. if ($sqm === null && !empty($parcelGeomResp['features'][0]['geometry']['rings'])) {
  144. $sqm = polygonAreaFromRingsSqm($parcelGeomResp['features'][0]['geometry']['rings']);
  145. }
  146. $total_area = null; $area_sqm = null; $area_ha = null;
  147. if ($sqm !== null) {
  148. $area_sqm = (float)$sqm;
  149. $area_ha = (float)$sqm / 10000.0;
  150. $total_area = [
  151. 'sqm' => $area_sqm,
  152. 'sqm_label' => number_format($area_sqm, 0) . ' sqm',
  153. 'ha' => $area_ha,
  154. 'ha_label' => rtrim(rtrim(number_format($area_ha, 4, '.', ''), '0'), '.') . ' ha'
  155. ];
  156. }
  157. $tenure = $parcelAttr['TENURE_TY'] ?? null;
  158. $lpi = $parcelAttr['LPI'] ?? null;
  159. $listGuid = $parcelAttr['LIST_GUID'] ?? null;
  160. // ---------------- LGA ----------------
  161. $lgasResp = arcgis_query($LAYER_LGA, $lng, $lat, '*', false);
  162. $lgaAttr = ($lgasResp['features'][0]['attributes'] ?? []);
  163. $council = $lgaAttr['LGA_NAME'] ?? $lgaAttr['NAME'] ?? $lgaAttr['COUNCIL'] ?? null;
  164. // ---------------- Zones ----------------
  165. $zonesResp = arcgis_query($LAYER_ZONES, $lng, $lat, '*', false);
  166. $zoneNames = [];
  167. $schemeName = null;
  168. foreach (($zonesResp['features'] ?? []) as $zf) {
  169. $a = $zf['attributes'] ?? [];
  170. foreach (['ZONE','ZONING','ZONE_NAME','ZONE_LABEL'] as $k) {
  171. if (!empty($a[$k])) { $zoneNames[] = $a[$k]; break; }
  172. }
  173. if (!$schemeName) $schemeName = $a['LPS'] ?? $a['SCHEME'] ?? 'Tasmanian Planning Scheme';
  174. }
  175. $zoneNames = array_values(array_unique(array_filter($zoneNames)));
  176. // ---------------- Codes ----------------
  177. $codesResp = arcgis_query($LAYER_CODES, $lng, $lat, '*', false);
  178. $codeNames = [];
  179. foreach (($codesResp['features'] ?? []) as $cf) {
  180. $a = $cf['attributes'] ?? [];
  181. foreach (['CODE','OVERLAY','OVERLAY_CODE','CODE_NAME','OVERLAY_DESC'] as $k) {
  182. if (!empty($a[$k])) { $codeNames[] = $a[$k]; break; }
  183. }
  184. }
  185. $codeNames = array_values(array_unique(array_filter($codeNames)));
  186. // Build a compact raw attributes cache (no geometry except parcel boundary you already add)
  187. $raw = [
  188. 'parcels' => array_values(array_map(
  189. fn($f) => $f['attributes'] ?? [],
  190. (array)($parcelsResp['features'] ?? [])
  191. )),
  192. 'lga' => array_values(array_map(
  193. fn($f) => $f['attributes'] ?? [],
  194. (array)($lgasResp['features'] ?? [])
  195. )),
  196. 'zones' => array_values(array_map(
  197. fn($f) => $f['attributes'] ?? [],
  198. (array)($zonesResp['features'] ?? [])
  199. )),
  200. 'codes' => array_values(array_map(
  201. fn($f) => $f['attributes'] ?? [],
  202. (array)($codesResp['features'] ?? [])
  203. )),
  204. ];
  205. // ---------------- Output ----------------
  206. $out = [
  207. 'ok' => true,
  208. 'pid' => $pid,
  209. 'title_id' => $titleId,
  210. 'tenure' => $tenure,
  211. 'lpi' => $lpi,
  212. 'list_guid' => $listGuid,
  213. 'total_area' => $total_area,
  214. 'area_sqm' => $area_sqm,
  215. 'area_ha' => $area_ha,
  216. 'council' => $council,
  217. 'planning_scheme' => $schemeName,
  218. 'planning_zones' => $zoneNames,
  219. 'planning_codes' => $codeNames,
  220. 'boundary' => $boundaryGeoJSON
  221. ];
  222. if ($debug) $out['debug'] = ['parcel_attrs'=>$parcelAttr, 'lga_attrs'=>$lgaAttr];
  223. // Cache a “full” version that includes raw attributes
  224. $cacheOut = $out;
  225. $cacheOut['raw'] = $raw;
  226. // Cache under snapped lat/lng immediately…
  227. ll_cache_set($preKey, $out);
  228. // …and if we have a PID, also cache under PID (best key)
  229. if ($pid) ll_cache_set('v2-pid-'.$pid, $cacheOut);
  230. // Return small by default; include raw only if requested
  231. $responseOut = $out;
  232. if ($full) $responseOut['raw'] = $raw;
  233. echo json_encode($responseOut, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
  234. } catch (Throwable $e) {
  235. http_response_code(500);
  236. echo json_encode(['ok'=>false,'error'=>$e->getMessage()]);
  237. }