| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070 |
- <?php require_once __DIR__ . '/_bootstrap.php';
- /* =========================================================================
- * Site Report – Tasmanian Property Lookup (PHP 8.3+)
- * -------------------------------------------------------------------------
- * - Google Maps API key served from environment, never exposed in source
- * - Enter address (TAS only) → pulls parcel + planning data via list_lookup.php
- * - Leaflet map with parcel overlay
- * - Generate AI report via generate_planning_report.php
- * - Open Section Builder with BroadcastChannel / postMessage handoff
- * =======================================================================*/
- // ── Security: API key from environment only, never hardcoded ──────────────
- // Set GMAPS_API_KEY in your .env / docker-compose environment block.
- // The key is passed to a PHP proxy endpoint (/gmaps-key.php) so it never
- // appears in page source. Falls back to direct injection only on localhost.
- $GMAPS_API_KEY = getenv('GMAPS_API_KEY') ?: '';
- $LOOKUP_ENDPOINT = './list_lookup.php';
- $REPORT_ENDPOINT = './generate_planning_report.php';
- // Use APP_ENV=local (set in .env) to enable the dev shortcut of inlining the
- // Maps key directly. Never derive this from REMOTE_ADDR — inside Docker the
- // client address is the container gateway (172.x.x.x), not 127.0.0.1, so the
- // old check always evaluated to false and the inline path was never reachable.
- $IS_LOCAL = (getenv('APP_ENV') === 'local');
- // Proxy the key: expose it only if request comes from same origin
- $KEY_ENDPOINT = './gmaps-key.php';
- ?>
- <!doctype html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>Property Lookup — Tasmanian Planning Scheme Assistant</title>
- <meta name="description" content="Look up SPPs and LPS rules, overlays, and codes for any Tasmanian property. Parcel data, zone summaries and AI report generation.">
- <link rel="canonical" href="https://tasplanning.report/site-report">
- <meta name="robots" content="noindex,follow">
- <link rel="preconnect" href="https://fonts.googleapis.com">
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
- <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&display=swap" rel="stylesheet">
- <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
- <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
- <script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
- <script defer src="https://unpkg.com/leaflet-image@latest/leaflet-image.js"></script>
- <script defer src="https://unpkg.com/html2pdf.js@0.10.1/dist/html2pdf.bundle.min.js"></script>
- <link rel="icon" href="/favicon.ico">
- <link rel="stylesheet" href="/css/design-tokens.css">
- <script src="/js/api-status.js" data-api="https://api.modulos.com.au"></script>
- <style>
- /* ── Page-specific token overrides ───────────────────────────────── */
- :root {
- --warn: #f0b060; /* slightly warmer than the shared default */
- --radius: 12px;
- --radius-lg: 18px;
- --radius-sm: 6px;
- --transition: 0.18s cubic-bezier(0.4,0,0.2,1);
- }
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- html { scroll-behavior: smooth; }
- body {
- font-family: var(--sans);
- background: var(--bg);
- color: var(--text-primary);
- font-size: 15px;
- line-height: 1.65;
- -webkit-font-smoothing: antialiased;
- min-height: 100vh;
- }
- ::selection { background: var(--accent); color: #0b0f0e; }
- a { color: var(--accent); text-decoration: none; }
- /* ── Nav ─────────────────────────────────────────────────────────── */
- .site-nav {
- position: sticky; top: 0; z-index: 200;
- background: rgba(11,15,14,0.9);
- backdrop-filter: blur(12px);
- border-bottom: 1px solid var(--border);
- }
- .nav-inner {
- max-width: 1280px; margin: 0 auto; padding: 0 24px;
- display: flex; align-items: center; justify-content: space-between;
- height: 58px;
- }
- .nav-brand {
- display: flex; align-items: center; gap: 10px;
- font-size: 0.88rem; font-weight: 500; color: var(--text-primary);
- text-decoration: none;
- }
- .nav-brand img { width: 26px; height: 26px; border-radius: 5px; }
- .nav-links { display: flex; align-items: center; gap: 4px; }
- .nav-links a {
- font-size: 0.82rem; color: var(--text-secondary); padding: 5px 11px;
- border-radius: var(--radius-sm); text-decoration: none;
- transition: all var(--transition);
- }
- .nav-links a:hover { color: var(--text-primary); background: rgba(255,255,255,0.05); }
- .nav-links a.active { color: var(--accent); }
- .status-dot {
- width: 7px; height: 7px; border-radius: 50%;
- background: var(--accent); box-shadow: 0 0 6px var(--accent-glow);
- display: inline-block;
- animation: pulse 2.5s ease-in-out infinite;
- }
- @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.45} }
- .nav-status { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: var(--text-muted); }
- /* ── Layout ──────────────────────────────────────────────────────── */
- .page-wrap { max-width: 1280px; margin: 0 auto; padding: 32px 24px 80px; }
- .page-header { margin-bottom: 28px; }
- .page-header h1 {
- font-family: var(--serif); font-size: clamp(1.6rem, 3vw, 2.2rem);
- line-height: 1.15; font-weight: 400; margin-bottom: 8px;
- }
- .page-header h1 em { font-style: italic; color: var(--accent); }
- .page-header p { font-size: 0.88rem; color: var(--text-secondary); }
- /* ── Cards ───────────────────────────────────────────────────────── */
- .card {
- background: var(--bg-card); border: 1px solid var(--border);
- border-radius: var(--radius-lg); padding: 24px;
- }
- .card + .card, .card + .results-card { margin-top: 16px; }
- /* ── Search form ─────────────────────────────────────────────────── */
- .search-label {
- font-size: 0.72rem; font-weight: 500; letter-spacing: 0.1em;
- text-transform: uppercase; color: var(--text-muted); margin-bottom: 10px;
- display: block;
- }
- .search-row { display: flex; gap: 10px; align-items: stretch; }
- .search-wrap { position: relative; flex: 1; }
- .search-input {
- width: 100%;
- background: var(--bg-1); border: 1px solid var(--border);
- border-radius: var(--radius); padding: 13px 16px 13px 44px;
- color: var(--text-primary); font-family: var(--sans); font-size: 0.93rem;
- outline: none; transition: border-color var(--transition), box-shadow var(--transition);
- }
- .search-input::placeholder { color: var(--text-muted); }
- .search-input:focus {
- border-color: var(--accent);
- box-shadow: 0 0 0 3px var(--accent-dim);
- }
- .search-icon {
- position: absolute; left: 15px; top: 50%; transform: translateY(-50%);
- color: var(--text-muted); font-size: 1rem; pointer-events: none;
- }
- .btn {
- display: inline-flex; align-items: center; gap: 7px;
- padding: 11px 20px; border-radius: var(--radius);
- font-family: var(--sans); font-size: 0.85rem; font-weight: 500;
- cursor: pointer; transition: all var(--transition);
- border: none; text-decoration: none; white-space: nowrap;
- }
- .btn-primary {
- background: var(--accent); color: #0b0f0e;
- }
- .btn-primary:hover:not(:disabled) {
- background: #3bf59a; transform: translateY(-1px);
- box-shadow: 0 0 16px var(--accent-glow);
- }
- .btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
- .btn-outline {
- background: transparent; color: var(--text-secondary);
- border: 1px solid var(--border-hover);
- }
- .btn-outline:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
- .btn-outline:disabled { opacity: 0.35; cursor: not-allowed; }
- .btn-ghost {
- background: transparent; color: var(--text-muted);
- border: 1px solid var(--border);
- }
- .btn-ghost:hover { border-color: var(--border-hover); color: var(--text-secondary); }
- .btn-sm { padding: 7px 14px; font-size: 0.78rem; border-radius: var(--radius-sm); }
- .hint-text { font-size: 0.75rem; color: var(--text-muted); margin-top: 9px; }
- .hint-text a { color: var(--text-secondary); border-bottom: 1px solid var(--border); }
- /* ── Error / status ──────────────────────────────────────────────── */
- .error-bar {
- display: none; align-items: center; gap: 9px;
- background: rgba(240,128,128,0.08); border: 1px solid rgba(240,128,128,0.25);
- border-radius: var(--radius-sm); padding: 10px 14px;
- color: var(--danger); font-size: 0.83rem; margin-top: 12px;
- }
- .error-bar.show { display: flex; }
- /* ── Map ─────────────────────────────────────────────────────────── */
- #pb-map {
- height: 320px; border-radius: var(--radius);
- overflow: hidden; background: var(--bg-2);
- border: 1px solid var(--border);
- }
- /* ── Results card ────────────────────────────────────────────────── */
- .results-card {
- background: var(--bg-card); border: 1px solid var(--border);
- border-radius: var(--radius-lg); overflow: hidden;
- animation: slideIn 0.3s ease;
- }
- @keyframes slideIn { from { opacity:0; transform: translateY(10px); } to { opacity:1; transform: none; } }
- .results-header {
- padding: 20px 24px 16px;
- border-bottom: 1px solid var(--border);
- display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
- }
- .results-address {
- font-family: var(--serif); font-size: 1.25rem; line-height: 1.2;
- color: var(--text-primary);
- }
- .council-badge {
- display: inline-flex; align-items: center; gap: 6px;
- background: var(--accent-dim); border: 1px solid rgba(45,220,138,0.2);
- border-radius: 999px; padding: 4px 12px;
- font-size: 0.75rem; color: var(--accent); white-space: nowrap; flex-shrink: 0;
- }
- .results-body { padding: 20px 24px; }
- /* Data grid */
- .data-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
- gap: 12px; margin-bottom: 20px;
- }
- .data-cell {
- background: var(--bg-2); border: 1px solid var(--border);
- border-radius: var(--radius-sm); padding: 12px 14px;
- }
- .data-cell .label {
- font-size: 0.68rem; font-weight: 500; letter-spacing: 0.09em;
- text-transform: uppercase; color: var(--text-muted); margin-bottom: 5px;
- }
- .data-cell .value {
- font-size: 0.88rem; color: var(--text-primary); font-weight: 500;
- word-break: break-word;
- }
- .data-cell.wide { grid-column: span 2; }
- .data-cell.accent-cell { border-color: rgba(45,220,138,0.2); }
- .data-cell.accent-cell .value { color: var(--accent); }
- /* Zone / code pills */
- .pill-group { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
- .zone-pill {
- display: inline-flex; align-items: center; gap: 5px;
- padding: 3px 10px; border-radius: 999px; font-size: 0.75rem;
- border: 1px solid rgba(45,220,138,0.25); color: var(--accent);
- background: var(--accent-dim);
- }
- .code-pill {
- display: inline-flex; align-items: center; gap: 5px;
- padding: 3px 10px; border-radius: 999px; font-size: 0.75rem;
- border: 1px solid var(--border); color: var(--text-secondary);
- background: var(--bg-2);
- }
- /* Divider */
- .divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
- /* Action bar */
- .action-bar {
- display: flex; flex-wrap: wrap; gap: 8px;
- padding: 16px 24px; border-top: 1px solid var(--border);
- background: var(--bg-1);
- }
- /* ── Report output ───────────────────────────────────────────────── */
- .report-card {
- background: var(--bg-card); border: 1px solid var(--border);
- border-radius: var(--radius-lg); margin-top: 16px;
- animation: slideIn 0.3s ease;
- }
- .report-header {
- padding: 16px 24px; border-bottom: 1px solid var(--border);
- display: flex; align-items: center; justify-content: space-between;
- }
- .report-header h3 { font-size: 0.9rem; font-weight: 500; }
- .report-body {
- padding: 24px;
- color: var(--text-secondary); font-size: 0.9rem; line-height: 1.75;
- }
- .report-body h1, .report-body h2, .report-body h3 {
- color: var(--text-primary); font-family: var(--serif);
- font-weight: 400; margin: 1.4em 0 0.6em;
- }
- .report-body h1 { font-size: 1.5rem; }
- .report-body h2 { font-size: 1.2rem; }
- .report-body h3 { font-size: 1rem; font-family: var(--sans); font-weight: 500; }
- .report-body p { margin-bottom: 0.8em; }
- .report-body ul, .report-body ol { padding-left: 1.4em; margin-bottom: 0.8em; }
- .report-body table { width: 100%; border-collapse: collapse; font-size: 0.83rem; margin: 1em 0; }
- .report-body th {
- background: var(--bg-2); color: var(--text-secondary);
- padding: 8px 10px; text-align: left; font-weight: 500;
- border-bottom: 1px solid var(--border); font-size: 0.75rem; letter-spacing: 0.05em;
- }
- .report-body td { padding: 8px 10px; border-bottom: 1px solid var(--border); color: var(--text-primary); }
- .report-body tr:last-child td { border-bottom: none; }
- /* Markdown panel */
- .md-panel { border-top: 1px solid var(--border); }
- .md-panel summary {
- padding: 12px 24px; font-size: 0.8rem; color: var(--text-muted);
- cursor: pointer; list-style: none; display: flex; align-items: center; gap: 8px;
- }
- .md-panel summary:hover { color: var(--text-secondary); }
- .md-panel summary::before { content: '›'; font-size: 1rem; transition: transform var(--transition); }
- .md-panel[open] summary::before { transform: rotate(90deg); }
- .md-textarea {
- width: 100%; background: var(--bg-2); border: none;
- border-top: 1px solid var(--border);
- color: var(--text-secondary); font-family: ui-monospace, 'Cascadia Code', Menlo, monospace;
- font-size: 0.78rem; line-height: 1.6; padding: 16px 24px; resize: vertical;
- min-height: 200px; outline: none;
- }
- .md-actions { display: flex; gap: 8px; padding: 12px 24px; border-top: 1px solid var(--border); }
- /* ── Spinner ──────────────────────────────────────────────────────── */
- .spinner {
- width: 15px; height: 15px;
- border: 2px solid var(--border); border-top-color: var(--accent);
- border-radius: 50%; animation: spin 0.65s linear infinite; flex-shrink: 0;
- }
- @keyframes spin { to { transform: rotate(360deg); } }
- /* ── Responsive ──────────────────────────────────────────────────── */
- @media(max-width: 640px) {
- .search-row { flex-direction: column; }
- .data-grid { grid-template-columns: repeat(2, 1fr); }
- .data-cell.wide { grid-column: span 2; }
- .action-bar { flex-direction: column; }
- .action-bar .btn { justify-content: center; }
- }
- /* ── Print ───────────────────────────────────────────────────────── */
- @media print {
- .site-nav, .action-bar, .btn, #pb-errors { display: none !important; }
- .results-card, .report-card { box-shadow: none; border: 1px solid #ddd; }
- body { background: #fff; color: #000; }
- }
- /* Leaflet dark override */
- .leaflet-tile { filter: brightness(0.85) saturate(0.7); }
- .leaflet-control-attribution { background: rgba(11,15,14,0.8) !important; color: var(--text-muted) !important; }
- .leaflet-control-attribution a { color: var(--accent) !important; }
- </style>
- <script>
- // ── Config (no sensitive keys in source) ─────────────────────────
- const LOOKUP_ENDPOINT = <?php echo json_encode($LOOKUP_ENDPOINT); ?>;
- const REPORT_ENDPOINT = <?php echo json_encode($REPORT_ENDPOINT); ?>;
- const KEY_ENDPOINT = <?php echo json_encode($KEY_ENDPOINT); ?>;
- <?php if ($IS_LOCAL && $GMAPS_API_KEY): ?>
- // localhost only — key injected directly for dev convenience
- const GMAPS_KEY = <?php echo json_encode($GMAPS_API_KEY); ?>;
- <?php else: ?>
- const GMAPS_KEY = null; // loaded via KEY_ENDPOINT proxy in initAutocomplete
- <?php endif; ?>
- </script>
- <!-- Google tag (gtag.js) -->
- <script async src="https://www.googletagmanager.com/gtag/js?id=G-LWEHQVCWEZ"></script>
- <script>
- window.dataLayer = window.dataLayer || [];
- function gtag(){dataLayer.push(arguments);}
- gtag('js', new Date());
- gtag('config', 'G-LWEHQVCWEZ');
- </script>
-
- <!-- Google Tag Manager -->
- <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
- new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
- j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
- 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
- })(window,document,'script','dataLayer','GTM-M5PFLGZT');</script>
- <!-- End Google Tag Manager -->
- </head>
- <body>
- <!-- Google Tag Manager (noscript) -->
- <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-M5PFLGZT"
- height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
- <!-- End Google Tag Manager (noscript) -->
- <!-- ── Nav ──────────────────────────────────────────────────────────── -->
- <nav class="site-nav">
- <div class="nav-inner">
- <a class="nav-brand" href="/">
- <svg width="26" height="26" viewBox="0 0 28 28" fill="none">
- <rect width="28" height="28" rx="6" fill="var(--accent-dim)" stroke="rgba(45,220,138,0.25)" stroke-width="1"/>
- <path d="M8 20 L14 8 L20 20" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M10.5 16 L17.5 16" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round"/>
- </svg>
- Tasmanian Planning Scheme
- </a>
- <div class="nav-links">
- <a href="/">Home</a>
- <a href="/local_state-planning-scheme.php">Assistant</a>
- <a href="/site-report.php" class="active">Property Lookup</a>
- <a href="/section-builder.php">Report Builder</a>
- <a href="/faq">FAQ</a>
- </div>
- <div class="nav-status">
- <span class="status-dot"></span>
- <span class="nav-status-text">API live</span>
- </div>
- </div>
- </nav>
-
- <!-- ── Main ─────────────────────────────────────────────────────────── -->
- <main class="page-wrap">
-
- <div class="page-header">
- <h1>Tasmanian <em>Property Lookup</em></h1>
- <p>Enter a Tasmanian address to retrieve parcel details, planning zones, overlays and codes — then generate an AI assessment report.</p>
- </div>
-
- <!-- Search card -->
- <div class="card">
- <label class="search-label" for="site_address">Site address — Tasmania only</label>
- <div class="search-row">
- <div class="search-wrap">
- <i class="bi bi-geo-alt search-icon"></i>
- <input id="site_address" class="search-input"
- placeholder="Start typing a Tasmanian address…"
- autocomplete="off" aria-label="Site address">
- </div>
- <button id="lookup-btn" class="btn btn-primary" disabled>
- <i class="bi bi-search"></i> Look up property
- </button>
- </div>
- <p class="hint-text">Select a suggestion from the dropdown. Restricted to Tasmania.</p>
- <div id="pb-errors" class="error-bar" role="alert">
- <i class="bi bi-exclamation-circle"></i>
- <span id="pb-errors-msg"></span>
- </div>
- </div>
-
- <!-- Hidden fields -->
- <input type="hidden" id="site_lat">
- <input type="hidden" id="site_lng">
- <input type="hidden" id="google_place_id">
- <input type="hidden" id="formatted_address">
- <input type="hidden" id="locality">
- <input type="hidden" id="state">
- <input type="hidden" id="postcode">
- <input type="hidden" id="property_id">
- <input type="hidden" id="title_id">
- <input type="hidden" id="planning_scheme">
- <input type="hidden" id="planning_zones">
- <input type="hidden" id="planning_codes">
- <input type="hidden" id="total_area">
-
- <!-- Results (hidden until lookup) -->
- <div id="pb-results" class="results-card" style="display:none;">
-
- <!-- Map -->
- <div style="padding: 16px 16px 0;">
- <div id="pb-map"></div>
- </div>
-
- <!-- Header -->
- <div class="results-header">
- <div>
- <div class="search-label" style="margin-bottom:6px;">Property identified</div>
- <div class="results-address" id="address">—</div>
- </div>
- <div id="summary-badge" class="council-badge" style="display:none;">
- <i class="bi bi-building"></i>
- <span id="council-name"></span>
- </div>
- </div>
-
- <!-- Data -->
- <div class="results-body">
- <!-- Core identifiers -->
- <div class="data-grid">
- <div class="data-cell accent-cell">
- <div class="label">Property ID</div>
- <div class="value" id="pb_pid">—</div>
- </div>
- <div class="data-cell">
- <div class="label">Title ID</div>
- <div class="value" id="pb_title">—</div>
- </div>
- <div class="data-cell">
- <div class="label">Total area</div>
- <div class="value" id="pb_area">—</div>
- </div>
- <div class="data-cell">
- <div class="label">Area m²</div>
- <div class="value" id="area_sqm">—</div>
- </div>
- <div class="data-cell">
- <div class="label">Area ha</div>
- <div class="value" id="area_ha">—</div>
- </div>
- <div class="data-cell">
- <div class="label">Tenure</div>
- <div class="value" id="tenure">—</div>
- </div>
- <div class="data-cell">
- <div class="label">Locality</div>
- <div class="value" id="pb_locality">—</div>
- </div>
- <div class="data-cell">
- <div class="label">LPI</div>
- <div class="value" id="lpi">—</div>
- </div>
- <div class="data-cell wide">
- <div class="label">Planning scheme</div>
- <div class="value" id="pb_scheme">—</div>
- </div>
- <div class="data-cell wide">
- <div class="label">LIST GUID</div>
- <div class="value" id="list_guid" style="font-size:0.75rem; font-family: ui-monospace, monospace;">—</div>
- </div>
- </div>
-
- <hr class="divider">
-
- <!-- Zones -->
- <div style="margin-bottom: 14px;">
- <div class="label" style="margin-bottom:8px;">Planning zones</div>
- <div class="pill-group" id="pb_zones_pills">
- <span style="color:var(--text-muted);font-size:0.8rem;">—</span>
- </div>
- </div>
-
- <!-- Codes -->
- <div>
- <div class="label" style="margin-bottom:8px;">Codes & overlays</div>
- <div class="pill-group" id="pb_codes_pills">
- <span style="color:var(--text-muted);font-size:0.8rem;">—</span>
- </div>
- </div>
- </div>
-
- <!-- Actions -->
- <div class="action-bar">
- <button id="pb-generate" class="btn btn-primary">
- <i class="bi bi-stars"></i> Generate AI report
- </button>
- <button id="pb-ask-assistant" class="btn btn-outline">
- <i class="bi bi-chat-dots"></i> Ask Assistant
- </button>
- <button id="pb-send-builder" class="btn btn-outline">
- <i class="bi bi-layout-text-sidebar"></i> Open section builder
- </button>
- <button id="pb-pdf" class="btn btn-ghost btn-sm">
- <i class="bi bi-file-pdf"></i> Save PDF
- </button>
- </div>
- </div>
-
- <!-- Report output -->
- <div id="pb-report" class="report-card" style="display:none;">
- <div class="report-header">
- <h3><i class="bi bi-file-earmark-text" style="color:var(--accent);margin-right:6px;"></i>AI Planning Report</h3>
- <div style="display:flex;gap:8px;">
- <button id="pb-pdf-report" class="btn btn-ghost btn-sm"><i class="bi bi-file-pdf"></i> Save PDF</button>
- </div>
- </div>
- <div class="report-body" id="pb-report-body"></div>
- <details class="md-panel" id="md-panel">
- <summary><i class="bi bi-code-slash"></i> Markdown source</summary>
- <textarea id="md-source" class="md-textarea" readonly></textarea>
- <div class="md-actions">
- <button id="md-copy" class="btn btn-ghost btn-sm"><i class="bi bi-clipboard"></i> Copy markdown</button>
- </div>
- </details>
- </div>
-
- </main>
-
- <!-- ── Google Maps loader ─────────────────────────────────────────── -->
- <script>
- 'use strict';
-
- /* ── Utilities ─────────────────────────────────────────────────────── */
- const LOG = (...a) => console.log('[SiteReport]', ...a);
- const $ = id => document.getElementById(id);
-
- function showError(msg) {
- const bar = $('pb-errors');
- const txt = $('pb-errors-msg');
- if (!bar) return;
- txt.textContent = msg;
- bar.classList.add('show');
- }
- function clearError() {
- const bar = $('pb-errors');
- if (bar) bar.classList.remove('show');
- }
-
- function setText(id, value) {
- const el = $(id);
- if (el) el.textContent = (value ?? '—');
- }
-
- function renderPills(containerId, items, pillClass) {
- const el = $(containerId);
- if (!el) return;
- if (!items || !items.length) {
- el.innerHTML = '<span style="color:var(--text-muted);font-size:0.8rem;">None identified</span>';
- return;
- }
- el.innerHTML = items.map(z =>
- `<span class="${pillClass}"><i class="bi bi-${pillClass === 'zone-pill' ? 'map' : 'tag'}"></i>${z}</span>`
- ).join('');
- }
-
- function showResults() {
- const c = $('pb-results');
- if (!c) return;
- c.style.display = '';
- requestAnimationFrame(() => c.scrollIntoView({ behavior: 'smooth', block: 'start' }));
- }
-
- window._pbCurrentData = null;
-
- /* ── Address autocomplete ──────────────────────────────────────────── */
- (function() {
- const TAS_BOUNDS = { north:-39.0, south:-44.5, east:149.5, west:143.0 };
- let inited = false;
-
- function isTas(components) {
- const a1 = components.find(c =>
- (c?.types?.includes?.('administrative_area_level_1')) || c?.type === 'administrative_area_level_1'
- );
- const v = (a1?.longText || a1?.long_name || a1?.shortText || a1?.short_name || '').toUpperCase();
- return v === 'TAS' || v === 'TASMANIA';
- }
-
- function writeHiddenFromPlace(place) {
- const latFn = place?.location?.lat || place?.geometry?.location?.lat;
- const lngFn = place?.location?.lng || place?.geometry?.location?.lng;
- const lat = typeof latFn === 'function' ? latFn.call(place.location || place.geometry.location) : null;
- const lng = typeof lngFn === 'function' ? lngFn.call(place.location || place.geometry.location) : null;
-
- const comps = place?.addressComponents || place?.address_components || [];
- const pick = type => {
- const c = comps.find(x => x?.types?.includes?.(type) || x?.type === type);
- return c ? (c.longText || c.long_name || '') : '';
- };
-
- if ($('site_lat')) $('site_lat').value = lat ?? '';
- if ($('site_lng')) $('site_lng').value = lng ?? '';
- if ($('google_place_id')) $('google_place_id').value = place.id || place.place_id || '';
- if ($('formatted_address'))$('formatted_address').value= place.formattedAddress || place.formatted_address || place.displayName || '';
- if ($('locality')) $('locality').value = pick('locality') || pick('postal_town') || pick('sublocality') || '';
- if ($('state')) $('state').value = pick('administrative_area_level_1') || '';
- if ($('postcode')) $('postcode').value = pick('postal_code') || '';
-
- $('lookup-btn').disabled = !(lat && lng);
- }
-
- function clearHidden() {
- ['site_lat','site_lng','google_place_id','formatted_address','locality','state','postcode']
- .forEach(id => { const el = $(id); if (el) el.value = ''; });
- if ($('lookup-btn')) $('lookup-btn').disabled = true;
- }
-
- async function handleNewWidgetEvent(ev, originalInput) {
- try {
- const pred = ev.placePrediction || ev.detail?.placePrediction || ev.detail;
- if (!pred || typeof pred.toPlace !== 'function') return;
- const place = pred.toPlace();
- await place.fetchFields({ fields: ['id','location','formattedAddress','addressComponents','displayName'] });
-
- const comps = place.addressComponents || [];
- if (!isTas(comps)) { showError('Please pick an address in Tasmania.'); clearHidden(); return; }
-
- clearError();
- if (originalInput) originalInput.value = place.formattedAddress || place.displayName || '';
- writeHiddenFromPlace(place);
- setText('address', place.formattedAddress || place.displayName || '');
- } catch(e) { LOG('error resolving prediction', e); }
- }
-
- async function loadGmapsKey() {
- if (GMAPS_KEY) return GMAPS_KEY;
- try {
- const r = await fetch(KEY_ENDPOINT);
- const d = await r.json();
- return d.key || '';
- } catch { return ''; }
- }
-
- window.initAutocomplete = async function() {
- if (inited) return;
- inited = true;
- const input = $('site_address');
- if (!input) return;
-
- const key = await loadGmapsKey();
- if (!key) { LOG('No Google Maps key available'); return; }
-
- // Use Google's official dynamic library import bootstrap.
- // This pattern is copy-pasted from Google's own documentation and
- // correctly handles the async initialisation of importLibrary().
- // See: https://developers.google.com/maps/documentation/javascript/load-maps-js-api
- if (!window.google?.maps?.importLibrary) {
- await new Promise((resolve, reject) => {
- // Inject the bootstrap loader exactly as Google recommends
- window.__googleMapsResolve = resolve;
- const g = { key, v: 'weekly', loading: 'async' };
- const script = document.createElement('script');
- script.src = 'https://maps.googleapis.com/maps/api/js?' +
- Object.entries(g).map(([k,v]) => `${k}=${encodeURIComponent(v)}`).join('&') +
- '&callback=__googleMapsCallback';
- script.async = true;
- script.onerror = reject;
- window.__googleMapsCallback = () => resolve();
- document.head.appendChild(script);
- });
- }
-
- // importLibrary() is now available. Wait for places library to be ready.
- let placesLib;
- try {
- placesLib = await google.maps.importLibrary('places');
- } catch(e) {
- LOG('Failed to import places library', e);
- return;
- }
-
- input.setAttribute('autocomplete', 'off');
- input.addEventListener('keydown', e => { if (e.key === 'Enter') e.preventDefault(); });
- $('lookup-btn').disabled = true;
-
- // Use new PlaceAutocompleteElement if available, else classic fallback
- if (placesLib.PlaceAutocompleteElement) {
- LOG('Using PlaceAutocompleteElement');
- const pac = new placesLib.PlaceAutocompleteElement({ includedRegionCodes: ['AU'] });
- pac.locationRestriction = TAS_BOUNDS;
- pac.includedPrimaryTypes = ['street_address', 'premise', 'route'];
- pac.className = input.className;
- pac.style.cssText = input.style.cssText;
- input.style.display = 'none';
- input.parentNode.insertBefore(pac, input);
- pac.addEventListener('input', clearHidden);
- pac.addEventListener('gmp-select', ev => handleNewWidgetEvent(ev, input));
- pac.addEventListener('gmp-placeselect', ev => handleNewWidgetEvent(ev, input));
- pac.addEventListener('gmpx-placechange', ev => handleNewWidgetEvent(ev, input));
- } else {
- LOG('Falling back to classic Autocomplete');
- const bounds = new google.maps.LatLngBounds(
- { lat: TAS_BOUNDS.south, lng: TAS_BOUNDS.west },
- { lat: TAS_BOUNDS.north, lng: TAS_BOUNDS.east }
- );
- const ac = new placesLib.Autocomplete(input, {
- componentRestrictions: { country: 'au' },
- fields: ['place_id','geometry','formatted_address','address_components'],
- types: ['geocode'], bounds, strictBounds: true
- });
- input.addEventListener('input', clearHidden);
- ac.addListener('place_changed', () => {
- const place = ac.getPlace();
- if (!place) return;
- const comps = place.address_components || [];
- if (!isTas(comps)) { showError('Please pick an address in Tasmania.'); clearHidden(); return; }
- clearError();
- writeHiddenFromPlace(place);
- setText('address', place.formatted_address || '');
- });
- }
- };
-
- // Kick off key loading and Maps init on page load
- window.addEventListener('load', () => window.initAutocomplete());
- })();
-
- /* ── Map ───────────────────────────────────────────────────────────── */
- let pbMap, pbParcelLayer;
-
- function ensureMap(lat, lng) {
- if (pbMap) return;
- pbMap = L.map('pb-map', { zoomControl: true });
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
- maxZoom: 20,
- attribution: '© OpenStreetMap',
- crossOrigin: true
- }).addTo(pbMap);
- pbParcelLayer = L.geoJSON(null, {
- style: { color: '#2ddc8a', weight: 2, fillOpacity: 0.08, fillColor: '#2ddc8a' }
- }).addTo(pbMap);
- pbMap.setView([lat || -42.882, lng || 147.33], 10);
- }
-
- function drawParcel(boundary) {
- if (!pbMap || !pbParcelLayer) return;
- pbParcelLayer.clearLayers();
- if (!boundary) return;
- const feature = boundary.type === 'Feature' ? boundary : { type:'Feature', geometry:boundary, properties:{} };
- pbParcelLayer.addData(feature);
- try {
- const b = pbParcelLayer.getBounds();
- if (b.isValid()) pbMap.fitBounds(b, { padding: [24, 24] });
- } catch {}
- }
-
- async function captureMapPng() {
- return new Promise(resolve => {
- if (!window.pbMap || typeof leafletImage !== 'function') return resolve(null);
- leafletImage(pbMap, (err, canvas) => {
- if (err || !canvas) return resolve(null);
- try { resolve(canvas.toDataURL('image/png')); } catch { resolve(null); }
- });
- });
- }
-
- /* ── Lookup ────────────────────────────────────────────────────────── */
- async function fetchAndExtractData() {
- const lat = $('site_lat')?.value;
- const lng = $('site_lng')?.value;
- if (!lat || !lng) { showError('Please select a Google-suggested address first.'); return; }
-
- clearError();
- const formatArea = a => a ? (a.sqm_label || a.ha_label || '') : '';
-
- try {
- const knownPid = $('property_id')?.value || window._pbCurrentData?.pid || null;
- const resp = await fetch(LOOKUP_ENDPOINT, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
- body: JSON.stringify({ lat: parseFloat(lat), lng: parseFloat(lng), pid: knownPid || undefined, full: true })
- });
- const data = await resp.json().catch(() => null);
- if (!resp.ok || !data || data.ok !== true) {
- showError((data && data.error) ? data.error : 'Lookup failed — please try again.');
- return;
- }
-
- const addrVal = $('formatted_address')?.value || $('site_address')?.value || '';
-
- // Fill text cells
- setText('pb_pid', data.pid || '—');
- setText('pb_title', data.title_id || '—');
- setText('pb_area', formatArea(data.total_area) || '—');
- setText('pb_locality',data.locality || '—');
- setText('pb_scheme', data.planning_scheme || '—');
- setText('area_sqm', data.area_sqm || '—');
- setText('area_ha', data.area_ha || '—');
- setText('tenure', data.tenure || '—');
- setText('lpi', data.lpi || '—');
- setText('list_guid', data.list_guid || '—');
- setText('address', addrVal || '—');
-
- // Council badge
- if (data.council) {
- $('council-name').textContent = data.council;
- $('summary-badge').style.display = 'inline-flex';
- } else {
- $('summary-badge').style.display = 'none';
- }
-
- // Zone pills
- renderPills('pb_zones_pills',
- Array.isArray(data.planning_zones) ? data.planning_zones : (data.planning_zones ? [data.planning_zones] : []),
- 'zone-pill'
- );
- // Code pills
- renderPills('pb_codes_pills',
- Array.isArray(data.planning_codes) ? data.planning_codes : (data.planning_codes ? [data.planning_codes] : []),
- 'code-pill'
- );
-
- // Store for downstream use
- window._pbCurrentData = {
- address: addrVal, lat: parseFloat(lat), lng: parseFloat(lng),
- pid: data.pid || null, title_id: data.title_id || null,
- council: data.council || null, planning_scheme: data.planning_scheme || null,
- planning_zones: data.planning_zones || [], planning_codes: data.planning_codes || [],
- total_area: data.total_area || null, boundary: data.boundary || null,
- locality: data.locality || null, area_sqm: data.area_sqm || null,
- area_ha: data.area_ha || null, tenure: data.tenure || null,
- lpi: data.lpi || null, list_guid: data.list_guid || null,
- raw: data.raw || null
- };
-
- const pidInput = $('property_id');
- if (pidInput && data.pid) pidInput.value = data.pid;
-
- showResults();
- ensureMap(parseFloat(lat), parseFloat(lng));
- drawParcel(data.boundary);
- if (!data.boundary && pbMap) pbMap.setView([parseFloat(lat), parseFloat(lng)], 17);
-
- // Resize map after reveal
- setTimeout(() => pbMap && pbMap.invalidateSize(), 150);
-
- } catch(e) {
- showError('Error fetching data: ' + e.message);
- }
- }
-
- /* ── Lookup button ─────────────────────────────────────────────────── */
- (function() {
- const btn = $('lookup-btn');
- const addr = $('site_address');
- if (!btn) return;
-
- addr?.addEventListener('keydown', e => {
- if (e.key === 'Enter' && !btn.disabled) { e.preventDefault(); btn.click(); }
- });
-
- btn.addEventListener('click', async e => {
- e.preventDefault();
- if (btn.disabled) return;
- const orig = btn.innerHTML;
- btn.disabled = true;
- btn.innerHTML = '<span class="spinner"></span> Looking up…';
- try { await fetchAndExtractData(); }
- finally { btn.disabled = false; btn.innerHTML = orig; }
- });
- })();
-
- /* ── Generate AI report ────────────────────────────────────────────── */
- (function() {
- document.addEventListener('DOMContentLoaded', () => {
- const genBtn = $('pb-generate');
- if (!genBtn) return;
-
- genBtn.addEventListener('click', async e => {
- e.preventDefault();
- const payload = window._pbCurrentData;
- if (!payload) { showError('Look up a property first.'); return; }
-
- const map_png = await captureMapPng().catch(() => null);
- if (map_png) payload.map_png = map_png;
-
- const orig = genBtn.innerHTML;
- genBtn.disabled = true;
- genBtn.innerHTML = '<span class="spinner"></span> Generating…';
-
- try {
- const resp = await fetch(REPORT_ENDPOINT, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
- body: JSON.stringify(payload)
- });
- const raw = await resp.text();
- let out = null;
- try { out = JSON.parse(raw); } catch {}
- if (!resp.ok || !out || out.ok !== true)
- throw new Error((out && out.error) ? out.error : `HTTP ${resp.status}`);
-
- const bodyEl = $('pb-report-body');
- bodyEl.innerHTML = out.html || '<p>No report content returned.</p>';
- $('pb-report').style.display = '';
- $('md-source').value = out.markdown || '';
- bodyEl.scrollIntoView({ behavior: 'smooth' });
- } catch(e) {
- showError('Report error: ' + e.message);
- } finally {
- genBtn.disabled = false;
- genBtn.innerHTML = orig;
- }
- });
- });
- })();
-
- /* ── PDF ───────────────────────────────────────────────────────────── */
- async function savePDF() {
- const wrap = $('pb-report');
- if (!wrap || wrap.style.display === 'none' || !$('pb-report-body')?.innerHTML.trim())
- return alert('Generate the AI report first, then save as PDF.');
- if (typeof html2pdf === 'undefined') { window.print(); return; }
-
- const rawAddr = ($('address')?.textContent || 'Property Report').trim();
- const safeAddr = rawAddr.replace(/[^ \w\-(),.]/g,'').replace(/\s+/g,' ').substring(0,120);
- const opts = {
- margin: [10,10,10,10],
- filename: `Planning Report — ${safeAddr}.pdf`,
- image: { type: 'jpeg', quality: 0.95 },
- html2canvas: { scale: 2, useCORS: true, logging: false },
- jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
- };
-
- const btn = $('pb-pdf') || $('pb-pdf-report');
- const orig = btn?.innerHTML;
- if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Preparing…'; }
- try { await html2pdf().from(wrap).set(opts).save(); }
- finally { if (btn) { btn.disabled = false; btn.innerHTML = orig; } }
- }
-
- $('pb-pdf')?.addEventListener('click', savePDF);
- $('pb-pdf-report')?.addEventListener('click', savePDF);
-
- /* ── Markdown copy ─────────────────────────────────────────────────── */
- $('md-copy')?.addEventListener('click', async () => {
- const ta = $('md-source');
- const btn = $('md-copy');
- if (!ta?.value) return;
- try {
- await navigator.clipboard.writeText(ta.value);
- const orig = btn.innerHTML;
- btn.innerHTML = '<i class="bi bi-check2"></i> Copied!';
- setTimeout(() => btn.innerHTML = orig, 1400);
- } catch { alert('Copy failed — select text and use Ctrl+C.'); }
- });
-
- /* ── Compose context (shared by section builder + assistant buttons) ── */
- function composeContext() {
- const d = window._pbCurrentData || {};
- return {
- address: d.address || '', lat: d.lat || null, lng: d.lng || null,
- pid: d.pid || null, title_id: d.title_id || null,
- council: d.council || null, locality: d.locality || null,
- planning_scheme: d.planning_scheme || null,
- planning_zones: d.planning_zones || [],
- planning_codes: d.planning_codes || [],
- total_area: d.total_area || null, area_sqm: d.area_sqm || null,
- area_ha: d.area_ha || null, tenure: d.tenure || null,
- lpi: d.lpi || null, list_guid: d.list_guid || null,
- map_png: d.map_png || null, proposal_summary: '', use_class: ''
- };
- }
- /* ── Ask Assistant button ──────────────────────────────────────────── */
- $('pb-ask-assistant')?.addEventListener('click', () => {
- if (!window._pbCurrentData) { showError('Look up a property first.'); return; }
- const ctx = composeContext();
- try {
- localStorage.setItem('tpr_builder_ctx', JSON.stringify({ ctx, written_at: Date.now() }));
- } catch(e) { console.warn('[SiteReport] localStorage write failed', e); }
- window.location.href = '/local_state-planning-scheme.php';
- });
- /* ── Open section builder ──────────────────────────────────────────── */
- (function() {
- const btn = $('pb-send-builder');
- if (!btn) return;
- const bc = ('BroadcastChannel' in window) ? new BroadcastChannel('planning_ctx') : null;
- btn.addEventListener('click', () => {
- if (!window._pbCurrentData) { showError('Look up a property first.'); return; }
- const ctx = composeContext();
-
- // Write context to sessionStorage BEFORE opening the tab.
- // section-builder.php reads it immediately on load — no timing issues,
- // no message passing, no retry loops needed.
- try {
- const payload = { ctx, written_at: Date.now() };
- localStorage.setItem('tpr_builder_ctx', JSON.stringify(payload));
- } catch(e) {
- console.warn('[SiteReport] localStorage write failed', e);
- }
- // Open the builder — it will find the context in localStorage
- const w = window.open('section-builder.php', '_blank', 'noopener');
-
- // Also send via postMessage as a fallback for same-tab / embedded use
- const payload = { type: 'ctx', payload: ctx };
- if (w) {
- // Give the new tab a moment to load, then postMessage once
- setTimeout(() => {
- try { w.postMessage(payload, location.origin); } catch {}
- }, 1200);
- }
- });
- })();
- </script>
-
- </body>
- </html>
|