list_lookup.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  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. // Write to a temp file then rename atomically so concurrent requests
  65. // that miss the cache simultaneously don't both write a partial file.
  66. $tmp = $f . '.tmp.' . getmypid();
  67. if (@file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE)) !== false) {
  68. @rename($tmp, $f);
  69. }
  70. }
  71. // Pre-cache key by snapped lat/lng (so we can serve before we know PID)
  72. $snap = sprintf('%0.6f,%0.6f', $lat, $lng);
  73. $preKey = "v2-latlng-".$snap;
  74. if ($cached = ll_cache_get($preKey)) {
  75. if (!$full) unset($cached['raw']);
  76. echo json_encode($cached, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
  77. exit;
  78. }
  79. // ---------------- Config ----------------
  80. $service = 'https://services.thelist.tas.gov.au/arcgis/rest/services/Public/PlanningOnline/MapServer';
  81. // ---------------- Helpers ----------------
  82. function arcgis_query($layerId, $lng, $lat, $outFields='*', $returnGeometry=false, $extraParams=[]) {
  83. global $service;
  84. $params = array_merge([
  85. 'f' => 'json',
  86. 'where' => '1=1',
  87. 'returnGeometry' => $returnGeometry ? 'true' : 'false',
  88. 'outFields' => $outFields,
  89. 'outSR' => '4326',
  90. 'geometryType' => 'esriGeometryPoint',
  91. 'spatialRel' => 'esriSpatialRelIntersects',
  92. 'inSR' => '4326',
  93. 'geometry' => json_encode(['x'=>(float)$lng, 'y'=>(float)$lat]),
  94. 'resultRecordCount'=> 200
  95. ], $extraParams);
  96. $url = "{$service}/{$layerId}/query";
  97. $ch = curl_init($url);
  98. curl_setopt_array($ch, [
  99. CURLOPT_POST => true,
  100. CURLOPT_POSTFIELDS => http_build_query($params),
  101. CURLOPT_RETURNTRANSFER => true,
  102. CURLOPT_TIMEOUT => 12,
  103. ]);
  104. $resp = curl_exec($ch);
  105. if ($resp === false) {
  106. $err = curl_error($ch); curl_close($ch);
  107. return ['error'=>"cURL error: $err"];
  108. }
  109. curl_close($ch);
  110. if (LL_THROTTLE_US > 0) usleep(LL_THROTTLE_US);
  111. return json_decode($resp, true);
  112. }
  113. // Web Mercator area helpers (compute sqm from rings if needed)
  114. function wmProject($lon, $lat) {
  115. if ($lat > 85.05112878) $lat = 85.05112878;
  116. if ($lat < -85.05112878) $lat = -85.05112878;
  117. $originShift = 20037508.342789244;
  118. $x = $lon * $originShift / 180.0;
  119. $y = log(tan((90 + $lat) * M_PI / 360.0)) * $originShift / M_PI;
  120. return [$x, $y];
  121. }
  122. function ringAreaSignedSqm(array $ring) {
  123. $n = count($ring); if ($n < 3) return 0.0;
  124. $sum = 0.0;
  125. for ($i=0; $i<$n; $i++) {
  126. $j = ($i+1) % $n;
  127. [$xi,$yi] = wmProject($ring[$i][0], $ring[$i][1]);
  128. [$xj,$yj] = wmProject($ring[$j][0], $ring[$j][1]);
  129. $sum += ($xi * $yj) - ($xj * $yi);
  130. }
  131. return 0.5 * $sum; // signed
  132. }
  133. function polygonAreaFromRingsSqm(array $rings) {
  134. $total = 0.0;
  135. foreach ($rings as $ring) $total += ringAreaSignedSqm($ring);
  136. return abs($total);
  137. }
  138. // ---------------- Layer IDs ----------------
  139. $LAYER_PARCELS = 2; // Cadastral Parcels
  140. $LAYER_LGA = 8; // Local Government Areas
  141. $LAYER_ZONES = 13; // Tas Planning Scheme Zones
  142. $LAYER_CODES = 14; // Tas Planning Scheme Code Overlays
  143. // ---------------- Parcels (attributes) ----------------
  144. $parcelsResp = arcgis_query($LAYER_PARCELS, $lng, $lat, '*', false);
  145. $parcelFeat = ($parcelsResp['features'][0] ?? null);
  146. $parcelAttr = $parcelFeat['attributes'] ?? [];
  147. // PID
  148. $pid = null;
  149. foreach (['PID','PROPERTY_ID'] as $k) {
  150. if (!empty($parcelAttr[$k])) { $pid = preg_replace('/[^0-9]/','', (string)$parcelAttr[$k]); break; }
  151. }
  152. // ---------------- Parcel geometry (polygon) ----------------
  153. $parcelGeomResp = arcgis_query($LAYER_PARCELS, $lng, $lat, 'PID,OBJECTID', true);
  154. $boundaryGeoJSON = null;
  155. if (!empty($parcelGeomResp['features'][0]['geometry']['rings'])) {
  156. $rings = $parcelGeomResp['features'][0]['geometry']['rings']; // 4326 due to outSR
  157. $boundaryGeoJSON = [
  158. 'type' => 'Feature',
  159. 'geometry' => [ 'type'=>'Polygon', 'coordinates'=>$rings ],
  160. 'properties' => [ 'pid'=>$pid ]
  161. ];
  162. }
  163. // ---------------- Title & Area ----------------
  164. $volume = $parcelAttr['VOLUME'] ?? null;
  165. $folio = isset($parcelAttr['FOLIO']) ? (string)$parcelAttr['FOLIO'] : null;
  166. $titleId = ($volume && $folio !== null && $folio !== '') ? ($volume . '/' . $folio)
  167. : ($parcelAttr['CT'] ?? $parcelAttr['CT_REFERENCE'] ?? null);
  168. $sqm = null;
  169. foreach (['MEAS_AREA','COMP_AREA','AREA_SQM','Shape__Area','SHAPE_Area'] as $k) {
  170. if (isset($parcelAttr[$k]) && is_numeric($parcelAttr[$k])) { $sqm = (float)$parcelAttr[$k]; break; }
  171. }
  172. if ($sqm === null && !empty($parcelGeomResp['features'][0]['geometry']['rings'])) {
  173. $sqm = polygonAreaFromRingsSqm($parcelGeomResp['features'][0]['geometry']['rings']);
  174. }
  175. $total_area = null; $area_sqm = null; $area_ha = null;
  176. if ($sqm !== null) {
  177. $area_sqm = (float)$sqm;
  178. $area_ha = (float)$sqm / 10000.0;
  179. $total_area = [
  180. 'sqm' => $area_sqm,
  181. 'sqm_label' => number_format($area_sqm, 0) . ' sqm',
  182. 'ha' => $area_ha,
  183. 'ha_label' => rtrim(rtrim(number_format($area_ha, 4, '.', ''), '0'), '.') . ' ha'
  184. ];
  185. }
  186. $tenure = $parcelAttr['TENURE_TY'] ?? null;
  187. $lpi = $parcelAttr['LPI'] ?? null;
  188. $listGuid = $parcelAttr['LIST_GUID'] ?? null;
  189. // ---------------- LGA ----------------
  190. $lgasResp = arcgis_query($LAYER_LGA, $lng, $lat, '*', false);
  191. $lgaAttr = ($lgasResp['features'][0]['attributes'] ?? []);
  192. $council = $lgaAttr['LGA_NAME'] ?? $lgaAttr['NAME'] ?? $lgaAttr['COUNCIL'] ?? null;
  193. // ---------------- Zones ----------------
  194. $zonesResp = arcgis_query($LAYER_ZONES, $lng, $lat, '*', false);
  195. $zoneNames = [];
  196. $schemeName = null;
  197. foreach (($zonesResp['features'] ?? []) as $zf) {
  198. $a = $zf['attributes'] ?? [];
  199. foreach (['ZONE','ZONING','ZONE_NAME','ZONE_LABEL'] as $k) {
  200. if (!empty($a[$k])) { $zoneNames[] = $a[$k]; break; }
  201. }
  202. if (!$schemeName) $schemeName = $a['LPS'] ?? $a['SCHEME'] ?? 'Tasmanian Planning Scheme';
  203. }
  204. $zoneNames = array_values(array_unique(array_filter($zoneNames)));
  205. // ---------------- Codes ----------------
  206. $codesResp = arcgis_query($LAYER_CODES, $lng, $lat, '*', false);
  207. $codeNames = [];
  208. foreach (($codesResp['features'] ?? []) as $cf) {
  209. $a = $cf['attributes'] ?? [];
  210. foreach (['CODE','OVERLAY','OVERLAY_CODE','CODE_NAME','OVERLAY_DESC'] as $k) {
  211. if (!empty($a[$k])) { $codeNames[] = $a[$k]; break; }
  212. }
  213. }
  214. $codeNames = array_values(array_unique(array_filter($codeNames)));
  215. // Build compact raw attributes (no geometry except parcel boundary is added separately)
  216. $raw = [
  217. 'parcels' => array_values(array_map(
  218. fn($f) => $f['attributes'] ?? [],
  219. (array)($parcelsResp['features'] ?? [])
  220. )),
  221. 'lga' => array_values(array_map(
  222. fn($f) => $f['attributes'] ?? [],
  223. (array)($lgasResp['features'] ?? [])
  224. )),
  225. 'zones' => array_values(array_map(
  226. fn($f) => $f['attributes'] ?? [],
  227. (array)($zonesResp['features'] ?? [])
  228. )),
  229. 'codes' => array_values(array_map(
  230. fn($f) => $f['attributes'] ?? [],
  231. (array)($codesResp['features'] ?? [])
  232. )),
  233. ];
  234. // ---------------- Output ----------------
  235. $out = [
  236. 'ok' => true,
  237. 'pid' => $pid,
  238. 'title_id' => $titleId,
  239. 'tenure' => $tenure,
  240. 'lpi' => $lpi,
  241. 'list_guid' => $listGuid,
  242. 'total_area' => $total_area,
  243. 'area_sqm' => $area_sqm,
  244. 'area_ha' => $area_ha,
  245. 'council' => $council,
  246. 'planning_scheme' => $schemeName,
  247. 'planning_zones' => $zoneNames,
  248. 'planning_codes' => $codeNames,
  249. 'boundary' => $boundaryGeoJSON
  250. ];
  251. if ($debug) {
  252. $out['debug'] = [
  253. 'parcel_attrs' => $parcelAttr,
  254. 'lga_attrs' => $lgaAttr,
  255. 'cache_dir' => ll_cache_base(), // show where we’re writing
  256. ];
  257. }
  258. // Cache a “full” version that includes raw attributes
  259. $cacheOut = $out;
  260. $cacheOut['raw'] = $raw;
  261. // Cache under snapped lat/lng immediately…
  262. ll_cache_set($preKey, $out);
  263. // …and if we have a PID, also cache under PID (best key)
  264. if ($pid) ll_cache_set('v2-pid-'.$pid, $cacheOut);
  265. // Return small by default; include raw only if requested
  266. $responseOut = $out;
  267. if ($full) $responseOut['raw'] = $raw;
  268. echo json_encode($responseOut, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
  269. } catch (Throwable $e) {
  270. http_response_code(500);
  271. echo json_encode(['ok'=>false,'error'=>$e->getMessage()]);
  272. }