false,'error'=>'Missing lat/lng']); exit; } $lat = (float)$js['lat']; $lng = (float)$js['lng']; $debug = !empty($js['debug']); $full = !empty($js['full']) || !empty($js['debug']); // ---- Cache directory selection (writable) ---- function ll_cache_base() { static $base = null; if ($base !== null) return $base; // 1) env override $env = getenv('CACHE_DIR'); if ($env && @is_dir($env) && @is_writable($env)) { $base = rtrim($env, '/'); return $base; } // 2) local ./cache (create) $local = __DIR__ . '/cache'; if (!is_dir($local)) { @mkdir($local, 0775, true); } if (@is_dir($local) && @is_writable($local)) { $base = $local; return $base; } // 3) fallback: /tmp/planning-cache $tmp = rtrim(sys_get_temp_dir(), '/').'/planning-cache'; if (!is_dir($tmp)) { @mkdir($tmp, 0777, true); } $base = $tmp; return $base; } function ll_cache_path($key) { $safe = preg_replace('/[^a-z0-9_.-]/i','_', $key).'.json'; return ll_cache_base().'/'.$safe; } function ll_cache_get($key){ $f = ll_cache_path($key); if (!is_file($f)) return null; if (filemtime($f) < time()-LL_CACHE_TTL) return null; $s = @file_get_contents($f); if ($s === false) return null; $j = json_decode($s, true); return $j ?: null; } function ll_cache_set($key,$data){ $f = ll_cache_path($key); if (!is_dir(dirname($f))) @mkdir(dirname($f), 0775, true); // Write to a temp file then rename atomically so concurrent requests // that miss the cache simultaneously don't both write a partial file. $tmp = $f . '.tmp.' . getmypid(); if (@file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE)) !== false) { @rename($tmp, $f); } } // Pre-cache key by snapped lat/lng (so we can serve before we know PID) $snap = sprintf('%0.6f,%0.6f', $lat, $lng); $preKey = "v2-latlng-".$snap; if ($cached = ll_cache_get($preKey)) { if (!$full) unset($cached['raw']); echo json_encode($cached, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE); exit; } // ---------------- Config ---------------- $service = 'https://services.thelist.tas.gov.au/arcgis/rest/services/Public/PlanningOnline/MapServer'; // ---------------- Helpers ---------------- function arcgis_query($layerId, $lng, $lat, $outFields='*', $returnGeometry=false, $extraParams=[]) { global $service; $params = array_merge([ 'f' => 'json', 'where' => '1=1', 'returnGeometry' => $returnGeometry ? 'true' : 'false', 'outFields' => $outFields, 'outSR' => '4326', 'geometryType' => 'esriGeometryPoint', 'spatialRel' => 'esriSpatialRelIntersects', 'inSR' => '4326', 'geometry' => json_encode(['x'=>(float)$lng, 'y'=>(float)$lat]), 'resultRecordCount'=> 200 ], $extraParams); $url = "{$service}/{$layerId}/query"; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query($params), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 12, ]); $resp = curl_exec($ch); if ($resp === false) { $err = curl_error($ch); curl_close($ch); return ['error'=>"cURL error: $err"]; } curl_close($ch); if (LL_THROTTLE_US > 0) usleep(LL_THROTTLE_US); return json_decode($resp, true); } // Web Mercator area helpers (compute sqm from rings if needed) function wmProject($lon, $lat) { if ($lat > 85.05112878) $lat = 85.05112878; if ($lat < -85.05112878) $lat = -85.05112878; $originShift = 20037508.342789244; $x = $lon * $originShift / 180.0; $y = log(tan((90 + $lat) * M_PI / 360.0)) * $originShift / M_PI; return [$x, $y]; } function ringAreaSignedSqm(array $ring) { $n = count($ring); if ($n < 3) return 0.0; $sum = 0.0; for ($i=0; $i<$n; $i++) { $j = ($i+1) % $n; [$xi,$yi] = wmProject($ring[$i][0], $ring[$i][1]); [$xj,$yj] = wmProject($ring[$j][0], $ring[$j][1]); $sum += ($xi * $yj) - ($xj * $yi); } return 0.5 * $sum; // signed } function polygonAreaFromRingsSqm(array $rings) { $total = 0.0; foreach ($rings as $ring) $total += ringAreaSignedSqm($ring); return abs($total); } // ---------------- Layer IDs ---------------- $LAYER_PARCELS = 2; // Cadastral Parcels $LAYER_LGA = 8; // Local Government Areas $LAYER_ZONES = 13; // Tas Planning Scheme Zones $LAYER_CODES = 14; // Tas Planning Scheme Code Overlays // ---------------- Parcels (attributes) ---------------- $parcelsResp = arcgis_query($LAYER_PARCELS, $lng, $lat, '*', false); $parcelFeat = ($parcelsResp['features'][0] ?? null); $parcelAttr = $parcelFeat['attributes'] ?? []; // PID $pid = null; foreach (['PID','PROPERTY_ID'] as $k) { if (!empty($parcelAttr[$k])) { $pid = preg_replace('/[^0-9]/','', (string)$parcelAttr[$k]); break; } } // ---------------- Parcel geometry (polygon) ---------------- $parcelGeomResp = arcgis_query($LAYER_PARCELS, $lng, $lat, 'PID,OBJECTID', true); $boundaryGeoJSON = null; if (!empty($parcelGeomResp['features'][0]['geometry']['rings'])) { $rings = $parcelGeomResp['features'][0]['geometry']['rings']; // 4326 due to outSR $boundaryGeoJSON = [ 'type' => 'Feature', 'geometry' => [ 'type'=>'Polygon', 'coordinates'=>$rings ], 'properties' => [ 'pid'=>$pid ] ]; } // ---------------- Title & Area ---------------- $volume = $parcelAttr['VOLUME'] ?? null; $folio = isset($parcelAttr['FOLIO']) ? (string)$parcelAttr['FOLIO'] : null; $titleId = ($volume && $folio !== null && $folio !== '') ? ($volume . '/' . $folio) : ($parcelAttr['CT'] ?? $parcelAttr['CT_REFERENCE'] ?? null); $sqm = null; foreach (['MEAS_AREA','COMP_AREA','AREA_SQM','Shape__Area','SHAPE_Area'] as $k) { if (isset($parcelAttr[$k]) && is_numeric($parcelAttr[$k])) { $sqm = (float)$parcelAttr[$k]; break; } } if ($sqm === null && !empty($parcelGeomResp['features'][0]['geometry']['rings'])) { $sqm = polygonAreaFromRingsSqm($parcelGeomResp['features'][0]['geometry']['rings']); } $total_area = null; $area_sqm = null; $area_ha = null; if ($sqm !== null) { $area_sqm = (float)$sqm; $area_ha = (float)$sqm / 10000.0; $total_area = [ 'sqm' => $area_sqm, 'sqm_label' => number_format($area_sqm, 0) . ' sqm', 'ha' => $area_ha, 'ha_label' => rtrim(rtrim(number_format($area_ha, 4, '.', ''), '0'), '.') . ' ha' ]; } $tenure = $parcelAttr['TENURE_TY'] ?? null; $lpi = $parcelAttr['LPI'] ?? null; $listGuid = $parcelAttr['LIST_GUID'] ?? null; // ---------------- LGA ---------------- $lgasResp = arcgis_query($LAYER_LGA, $lng, $lat, '*', false); $lgaAttr = ($lgasResp['features'][0]['attributes'] ?? []); $council = $lgaAttr['LGA_NAME'] ?? $lgaAttr['NAME'] ?? $lgaAttr['COUNCIL'] ?? null; // ---------------- Zones ---------------- $zonesResp = arcgis_query($LAYER_ZONES, $lng, $lat, '*', false); $zoneNames = []; $schemeName = null; foreach (($zonesResp['features'] ?? []) as $zf) { $a = $zf['attributes'] ?? []; foreach (['ZONE','ZONING','ZONE_NAME','ZONE_LABEL'] as $k) { if (!empty($a[$k])) { $zoneNames[] = $a[$k]; break; } } if (!$schemeName) $schemeName = $a['LPS'] ?? $a['SCHEME'] ?? 'Tasmanian Planning Scheme'; } $zoneNames = array_values(array_unique(array_filter($zoneNames))); // ---------------- Codes ---------------- $codesResp = arcgis_query($LAYER_CODES, $lng, $lat, '*', false); $codeNames = []; foreach (($codesResp['features'] ?? []) as $cf) { $a = $cf['attributes'] ?? []; foreach (['CODE','OVERLAY','OVERLAY_CODE','CODE_NAME','OVERLAY_DESC'] as $k) { if (!empty($a[$k])) { $codeNames[] = $a[$k]; break; } } } $codeNames = array_values(array_unique(array_filter($codeNames))); // Build compact raw attributes (no geometry except parcel boundary is added separately) $raw = [ 'parcels' => array_values(array_map( fn($f) => $f['attributes'] ?? [], (array)($parcelsResp['features'] ?? []) )), 'lga' => array_values(array_map( fn($f) => $f['attributes'] ?? [], (array)($lgasResp['features'] ?? []) )), 'zones' => array_values(array_map( fn($f) => $f['attributes'] ?? [], (array)($zonesResp['features'] ?? []) )), 'codes' => array_values(array_map( fn($f) => $f['attributes'] ?? [], (array)($codesResp['features'] ?? []) )), ]; // ---------------- Output ---------------- $out = [ 'ok' => true, 'pid' => $pid, 'title_id' => $titleId, 'tenure' => $tenure, 'lpi' => $lpi, 'list_guid' => $listGuid, 'total_area' => $total_area, 'area_sqm' => $area_sqm, 'area_ha' => $area_ha, 'council' => $council, 'planning_scheme' => $schemeName, 'planning_zones' => $zoneNames, 'planning_codes' => $codeNames, 'boundary' => $boundaryGeoJSON ]; if ($debug) { $out['debug'] = [ 'parcel_attrs' => $parcelAttr, 'lga_attrs' => $lgaAttr, 'cache_dir' => ll_cache_base(), // show where we’re writing ]; } // Cache a “full” version that includes raw attributes $cacheOut = $out; $cacheOut['raw'] = $raw; // Cache under snapped lat/lng immediately… ll_cache_set($preKey, $out); // …and if we have a PID, also cache under PID (best key) if ($pid) ll_cache_set('v2-pid-'.$pid, $cacheOut); // Return small by default; include raw only if requested $responseOut = $out; if ($full) $responseOut['raw'] = $raw; echo json_encode($responseOut, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE); } catch (Throwable $e) { http_response_code(500); echo json_encode(['ok'=>false,'error'=>$e->getMessage()]); }