list_lookup.php 11 KB

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