Permit-map iframe embed, lu_parcels schema, security guards, LayerSwitcher fix
Iframe embed for the Permitting app (LUPMIS2_Reusable_Mapping_Concept §3.2): - public/embed.php — SSO + production gate + frame-ancestors CSP + whitelisted URL params (mode, lon/lat/zoom, upn, basemap, application_code); injects window.LUPMIS_SESSION + window.LUPMIS_EMBED. - public/.htaccess — clean /embed URL (rewrite before the SPA fallback). - src/embed-bridge.js — postMessage protocol: out ready / parcel:select / parcel:cleared / error; in set:view / set:selected / clear:selected / set:basemap. Visual highlight via a dedicated VectorLayer; pending-UPN queue resolved as parcels stream in. - main.js — reads window.LUPMIS_EMBED, gates the normal click/dblclick handlers in permit mode, exposes parcelsLayer to module scope, makes it visible and hands it to the bridge after loadParcels(). - index.html — CSS for body.embed-mode-permit hides navbar/dock/offcanvas and lets the map fill the iframe. - LUPMIS2_Permit_Map_Integration.docx — integration instructions for the Permitting team (contract, show.blade.php changes, phasing). Local lu_parcels structural refactor: - src/database.js — parcels table now mirrors spatial.lu_parcels with explicit columns (upn, style, landuse, zone_code/name, sector, block, parcel_no, prop_no, st_name, prop_add, fac_name, min/max_height, eff_date, lp_name, locality, mmda, last_update, remarks, geom→geometry_wkt, created_at, updated_at, districtid) plus local-only status/fetched_at. Drop-and-recreate migration off `upn` presence. saveParcels wraps the ~25k inserts in a transaction; numeric coercion via numOrNull. updateParcel/insertNewParcel write individual columns. - main.js parcelsToGeoJSON — handles GeoJSON `geom` object (API) and `geometry_wkt` string (local cache); skips housekeeping fields. Production access guard + no-district overlay: - public/index.php — on *.lupmis4luspa.org, redirect to the SSO portal if no session. - src/remotedb.js resolveDistrictId — no silent fallback to '1' for an authenticated user; dev mode (no session at all) keeps the fallback. - main.js — blocking overlay if the session lacks district_id; init aborts so no API call is made with the wrong scope. LayerSwitcher ordering fix: - MapView.initEditBar + MapTools — find the Overlays group by reference / title instead of assuming it's the last layer (the GPS layers add-layered on top in the constructor broke that assumption). Service Worker v8 → v9 to evict stale shell/module caches on deploy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cfaceb3487
commit
933bfcf4c0
BIN
LUPMIS2_Permit_Map_Integration.docx
Normal file
BIN
LUPMIS2_Permit_Map_Integration.docx
Normal file
Binary file not shown.
12
dist/.htaccess
vendored
12
dist/.htaccess
vendored
@ -13,11 +13,17 @@ DirectoryIndex index.php index.html
|
|||||||
SetHandler application/x-httpd-php
|
SetHandler application/x-httpd-php
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
# Common single-page-app behaviour: if a route doesn't map to a real file or
|
|
||||||
# directory, send the request to index.php so the PWA can handle it client-side.
|
|
||||||
# Comment out the next block if hash-based routing is preferred (no rewrites).
|
|
||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Clean URL for the iframe embed endpoint: /embed → embed.php
|
||||||
|
# Must come BEFORE the SPA fallback so /embed doesn't get routed to
|
||||||
|
# index.php. Query strings (?mode=permit&...) pass through automatically.
|
||||||
|
RewriteRule ^embed/?$ embed.php [L]
|
||||||
|
|
||||||
|
# Common single-page-app behaviour: if a route doesn't map to a real file
|
||||||
|
# or directory, send the request to index.php so the PWA can handle it
|
||||||
|
# client-side. Comment out this block if hash-based routing is preferred.
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteRule ^ index.php [L]
|
RewriteRule ^ index.php [L]
|
||||||
|
|||||||
707
dist/assets/index-DJ2WL3EC.js
vendored
707
dist/assets/index-DJ2WL3EC.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-DJ2WL3EC.js.map
vendored
1
dist/assets/index-DJ2WL3EC.js.map
vendored
File diff suppressed because one or more lines are too long
803
dist/assets/index-YjHYbDyk.js
vendored
Normal file
803
dist/assets/index-YjHYbDyk.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-YjHYbDyk.js.map
vendored
Normal file
1
dist/assets/index-YjHYbDyk.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
157
dist/embed.php
vendored
Normal file
157
dist/embed.php
vendored
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* LUPMIS2 PWA — Iframe embed entry point
|
||||||
|
*
|
||||||
|
* Same SSO model as index.php (cookie validate → session → inject
|
||||||
|
* window.LUPMIS_SESSION), with three things added for the iframe channel
|
||||||
|
* defined by LUPMIS2_Reusable_Mapping_Concept.docx §3.2 / §4 and the
|
||||||
|
* Permit Map Integration document §2:
|
||||||
|
*
|
||||||
|
* 1. URL parameters (mode, lon, lat, zoom, upn, basemap, application_code)
|
||||||
|
* are parsed and exposed as `window.LUPMIS_EMBED` so the PWA can
|
||||||
|
* run in a slim, single-purpose mode (see main.js + src/embed-bridge.js).
|
||||||
|
* 2. `Content-Security-Policy: frame-ancestors` restricts who may embed
|
||||||
|
* this page — never `*`. The list comes from EMBED_ALLOWED_PARENTS
|
||||||
|
* below and should be tightened to the real permitting host once it
|
||||||
|
* is confirmed.
|
||||||
|
* 3. A body class hint (`embed embed-mode-permit`) is set client-side
|
||||||
|
* via the inline script so CSS can hide the unrelated chrome (navbar,
|
||||||
|
* bottom dock, account offcanvas, etc.).
|
||||||
|
*/
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SSO authentication — validate the cookie if we don't already have a session
|
||||||
|
// (same logic as index.php)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
||||||
|
$plainToken = $_COOKIE['sso_auth_token'];
|
||||||
|
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);
|
||||||
|
|
||||||
|
$curl = curl_init();
|
||||||
|
curl_setopt_array($curl, [
|
||||||
|
CURLOPT_URL => $validate_url,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_ENCODING => "",
|
||||||
|
CURLOPT_MAXREDIRS => 10,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
||||||
|
CURLOPT_CUSTOMREQUEST => "GET",
|
||||||
|
CURLOPT_HTTPHEADER => [ "Content-Type: application/xml" ],
|
||||||
|
]);
|
||||||
|
$response = curl_exec($curl);
|
||||||
|
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($curl);
|
||||||
|
|
||||||
|
if ($httpCode === 200) {
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
if (
|
||||||
|
is_array($data)
|
||||||
|
&& isset($data['valid']) && $data['valid'] === true
|
||||||
|
&& isset($data['logged_in_user']) && is_array($data['logged_in_user'])
|
||||||
|
) {
|
||||||
|
foreach ($data['logged_in_user'] as $key => $value) {
|
||||||
|
$_SESSION[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Production access guard — same rule as index.php
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
|
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
|
||||||
|
if ($isProduction && !isset($_SESSION['user_id'])) {
|
||||||
|
header('Location: https://lupmis4luspa.org/', true, 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Session payload (same shape as index.php)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
$payload = [];
|
||||||
|
if (isset($_SESSION['user_id'])) {
|
||||||
|
$payload = [
|
||||||
|
'user_id' => $_SESSION['user_id'] ?? null,
|
||||||
|
'ua_id' => $_SESSION['ua_id'] ?? null,
|
||||||
|
'username' => $_SESSION['username'] ?? null,
|
||||||
|
'title' => $_SESSION['title'] ?? null,
|
||||||
|
'full_name' => $_SESSION['full_name'] ?? null,
|
||||||
|
'email' => $_SESSION['email'] ?? null,
|
||||||
|
'user_type' => $_SESSION['user_type'] ?? null,
|
||||||
|
'phone' => $_SESSION['phone'] ?? null,
|
||||||
|
'ua_position' => $_SESSION['ua_position'] ?? null,
|
||||||
|
'region_id' => $_SESSION['region_id'] ?? null,
|
||||||
|
'district_id' => $_SESSION['district_id'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Embed config — parse the contract's URL parameters (see Permit Map
|
||||||
|
// Integration doc §2.1). Strict whitelisting + type coercion keeps invalid
|
||||||
|
// input from reaching the PWA.
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
$validBasemaps = ['topo','osm','satellite','googlesat','carto-light','carto-dark','none'];
|
||||||
|
$validModes = ['permit'];
|
||||||
|
|
||||||
|
$mode = isset($_GET['mode']) ? (string)$_GET['mode'] : 'permit';
|
||||||
|
$basemap = isset($_GET['basemap']) ? (string)$_GET['basemap'] : null;
|
||||||
|
$upn = isset($_GET['upn']) ? (string)$_GET['upn'] : null;
|
||||||
|
$appCode = isset($_GET['application_code']) ? (string)$_GET['application_code'] : null;
|
||||||
|
|
||||||
|
$lon = isset($_GET['lon']) && is_numeric($_GET['lon']) ? (float)$_GET['lon'] : null;
|
||||||
|
$lat = isset($_GET['lat']) && is_numeric($_GET['lat']) ? (float)$_GET['lat'] : null;
|
||||||
|
$zoom = isset($_GET['zoom']) && is_numeric($_GET['zoom']) ? (int)$_GET['zoom'] : null;
|
||||||
|
|
||||||
|
$embed = [
|
||||||
|
'mode' => in_array($mode, $validModes, true) ? $mode : 'permit',
|
||||||
|
'lon' => $lon,
|
||||||
|
'lat' => $lat,
|
||||||
|
'zoom' => $zoom,
|
||||||
|
'upn' => $upn,
|
||||||
|
'basemap' => ($basemap && in_array($basemap, $validBasemaps, true)) ? $basemap : null,
|
||||||
|
'application_code' => $appCode,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Read the built index.html and inject the session + embed config
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
$indexPath = __DIR__ . '/index.html';
|
||||||
|
$html = is_readable($indexPath)
|
||||||
|
? file_get_contents($indexPath)
|
||||||
|
: '<!DOCTYPE html><html><body><h1>LUPMIS2 PWA</h1><p>index.html is missing.</p></body></html>';
|
||||||
|
|
||||||
|
$jsonFlags = JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
|
||||||
|
$sessionJson = json_encode($payload, $jsonFlags);
|
||||||
|
$embedJson = json_encode($embed, $jsonFlags);
|
||||||
|
$inject =
|
||||||
|
"<script>window.LUPMIS_SESSION = {$sessionJson};" .
|
||||||
|
"window.LUPMIS_EMBED = {$embedJson};" .
|
||||||
|
// Set the body class as early as possible so CSS rules apply on first paint.
|
||||||
|
"document.addEventListener('DOMContentLoaded',function(){" .
|
||||||
|
"document.body.classList.add('embed','embed-mode-' + (window.LUPMIS_EMBED.mode || 'permit'));" .
|
||||||
|
"});</script>";
|
||||||
|
|
||||||
|
$html = preg_replace('/<head\b[^>]*>/i', '$0' . "\n " . $inject, $html, 1);
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Headers — frame-ancestors restricts who may embed; tighten this list once
|
||||||
|
// the real permitting host is confirmed. NEVER use `frame-ancestors *`.
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
$EMBED_ALLOWED_PARENTS = [
|
||||||
|
'https://permits.lupmis4luspa.org',
|
||||||
|
// Add local dev parents here if needed, e.g. 'http://localhost:8000'.
|
||||||
|
];
|
||||||
|
$frameAncestors = "'self' " . implode(' ', $EMBED_ALLOWED_PARENTS);
|
||||||
|
|
||||||
|
header("Content-Security-Policy: frame-ancestors {$frameAncestors}");
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store, must-revalidate');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||||
|
|
||||||
|
echo $html;
|
||||||
29
dist/index.html
vendored
29
dist/index.html
vendored
@ -1525,6 +1525,33 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Iframe embed mode (see public/embed.php + src/embed-bridge.js).
|
||||||
|
The body class is set by the inline script injected by embed.php
|
||||||
|
(`embed embed-mode-permit`). In permit mode the iframe is hosted
|
||||||
|
inside the permitting app, so all LUPMIS2 chrome is hidden —
|
||||||
|
only the map and its floating controls remain visible. The map
|
||||||
|
container expands to fill the viewport.
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
body.embed-mode-permit .navbar,
|
||||||
|
body.embed-mode-permit .bottom-dock,
|
||||||
|
body.embed-mode-permit .offcanvas,
|
||||||
|
body.embed-mode-permit .offcanvas-toggle,
|
||||||
|
body.embed-mode-permit #install-btn,
|
||||||
|
body.embed-mode-permit #offline-indicator,
|
||||||
|
body.embed-mode-permit .map-tools-bar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
body.embed-mode-permit .main-content,
|
||||||
|
body.embed-mode-permit .map-container {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body.embed-mode-permit #map {
|
||||||
|
height: 100svh;
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
.ol-editbar.ol-bar {
|
.ol-editbar.ol-bar {
|
||||||
/* NOTE: display must NOT be !important — ol-ext toggles the bar via an
|
/* NOTE: display must NOT be !important — ol-ext toggles the bar via an
|
||||||
@ -1571,7 +1598,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/index-DJ2WL3EC.js"></script>
|
<script type="module" crossorigin src="/assets/index-YjHYbDyk.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/openlayers-CvK8xBSr.js">
|
<link rel="modulepreload" crossorigin href="/assets/openlayers-CvK8xBSr.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
|
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/ol-ext-BR0zF6aa.js">
|
<link rel="modulepreload" crossorigin href="/assets/ol-ext-BR0zF6aa.js">
|
||||||
|
|||||||
15
dist/index.php
vendored
15
dist/index.php
vendored
@ -65,6 +65,21 @@ if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Production access guard
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// On the public production host, calling this file without a valid SSO session
|
||||||
|
// is not allowed — bounce the visitor to the central LUSPA login portal. On
|
||||||
|
// local development (any host that is not *.lupmis4luspa.org, e.g. localhost,
|
||||||
|
// 127.0.0.1, a developer's .local hostname) the guard is bypassed so the PHP
|
||||||
|
// entry point can still be exercised directly during testing.
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
|
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
|
||||||
|
if ($isProduction && !isset($_SESSION['user_id'])) {
|
||||||
|
header('Location: https://lupmis4luspa.org/', true, 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
// Build the payload exposed to the PWA as window.LUPMIS_SESSION
|
// Build the payload exposed to the PWA as window.LUPMIS_SESSION
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
6
dist/sw.js
vendored
6
dist/sw.js
vendored
@ -29,7 +29,11 @@
|
|||||||
// mobile drawing-toolbar wrap, base-map "None" option, and the Safari
|
// mobile drawing-toolbar wrap, base-map "None" option, and the Safari
|
||||||
// 100svh dock fix. New hashed bundle + updated shell — bump to evict the
|
// 100svh dock fix. New hashed bundle + updated shell — bump to evict the
|
||||||
// old module/shell caches.
|
// old module/shell caches.
|
||||||
const CACHE_VERSION = 'v8';
|
// v9: Iframe embed endpoint (/embed via public/embed.php) + postMessage bridge
|
||||||
|
// for the permitting integration; lu_parcels structural refactor in the
|
||||||
|
// local DB; production access guard + no-district overlay; LayerSwitcher
|
||||||
|
// ordering fix. New shell + hashed bundle.
|
||||||
|
const CACHE_VERSION = 'v9';
|
||||||
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
||||||
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
||||||
const API_CACHE = `api-${CACHE_VERSION}`;
|
const API_CACHE = `api-${CACHE_VERSION}`;
|
||||||
|
|||||||
27
index.html
27
index.html
@ -1525,6 +1525,33 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Iframe embed mode (see public/embed.php + src/embed-bridge.js).
|
||||||
|
The body class is set by the inline script injected by embed.php
|
||||||
|
(`embed embed-mode-permit`). In permit mode the iframe is hosted
|
||||||
|
inside the permitting app, so all LUPMIS2 chrome is hidden —
|
||||||
|
only the map and its floating controls remain visible. The map
|
||||||
|
container expands to fill the viewport.
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
body.embed-mode-permit .navbar,
|
||||||
|
body.embed-mode-permit .bottom-dock,
|
||||||
|
body.embed-mode-permit .offcanvas,
|
||||||
|
body.embed-mode-permit .offcanvas-toggle,
|
||||||
|
body.embed-mode-permit #install-btn,
|
||||||
|
body.embed-mode-permit #offline-indicator,
|
||||||
|
body.embed-mode-permit .map-tools-bar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
body.embed-mode-permit .main-content,
|
||||||
|
body.embed-mode-permit .map-container {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body.embed-mode-permit #map {
|
||||||
|
height: 100svh;
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
.ol-editbar.ol-bar {
|
.ol-editbar.ol-bar {
|
||||||
/* NOTE: display must NOT be !important — ol-ext toggles the bar via an
|
/* NOTE: display must NOT be !important — ol-ext toggles the bar via an
|
||||||
|
|||||||
117
main.js
117
main.js
@ -88,20 +88,93 @@ import { checkServerReachable, isServerReachable, getDistrictBoundary, getLayers
|
|||||||
import { geoTracker } from './src/geotracker-lupmis.js';
|
import { geoTracker } from './src/geotracker-lupmis.js';
|
||||||
import { formatCoord, formatAccuracy, formatDistance, accuracyQuality } from './src/geotracker/geo-utils.js';
|
import { formatCoord, formatAccuracy, formatDistance, accuracyQuality } from './src/geotracker/geo-utils.js';
|
||||||
|
|
||||||
|
// Iframe embed bridge (see public/embed.php + LUPMIS2_Permit_Map_Integration.docx)
|
||||||
|
import { createEmbedBridge } from './src/embed-bridge.js';
|
||||||
|
|
||||||
// Map instance (global for access across functions)
|
// Map instance (global for access across functions)
|
||||||
let mapView = null;
|
let mapView = null;
|
||||||
let mapTools = null;
|
let mapTools = null;
|
||||||
|
// Module-level reference so the embed bridge can access the parcels layer
|
||||||
|
// once loadParcels() has created it.
|
||||||
|
let parcelsLayer = null;
|
||||||
|
let embedBridge = null;
|
||||||
|
|
||||||
|
// Iframe embed mode. Set by public/embed.php when serving the /embed route;
|
||||||
|
// undefined for the normal /index.php entry point.
|
||||||
|
const EMBED_CONFIG = (typeof window !== 'undefined' && window.LUPMIS_EMBED) || null;
|
||||||
|
const IS_EMBED_PERMIT = !!(EMBED_CONFIG && EMBED_CONFIG.mode === 'permit');
|
||||||
|
|
||||||
// Current interaction mode: 'addLocation' | 'measureCircle' | 'measureLine' | 'measureArea'
|
// Current interaction mode: 'addLocation' | 'measureCircle' | 'measureLine' | 'measureArea'
|
||||||
let currentMode = 'addLocation';
|
// In embed permit mode we don't want the default Add-Location click to fire,
|
||||||
|
// so start the mode in a neutral state.
|
||||||
|
let currentMode = IS_EMBED_PERMIT ? 'embed-permit' : 'addLocation';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Application Initialization
|
// Application Initialization
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-flight: when an SSO session is present but the user has no district
|
||||||
|
* assigned, the app cannot function (every API call is scoped to a district).
|
||||||
|
* Show a blocking message and halt initialisation so we never silently fall
|
||||||
|
* back to a default district.
|
||||||
|
*
|
||||||
|
* Local dev (no window.LUPMIS_SESSION at all) is *not* affected — that path
|
||||||
|
* still uses the remotedb FALLBACK_DISTRICT_ID for testing.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if the user is blocked (init should abort)
|
||||||
|
*/
|
||||||
|
function showNoDistrictBlockerIfNeeded() {
|
||||||
|
const session = (typeof window !== 'undefined') ? window.LUPMIS_SESSION : null;
|
||||||
|
if (!session || typeof session !== 'object') return false; // dev mode
|
||||||
|
const id = session.district_id;
|
||||||
|
if (id !== null && id !== undefined && String(id).length > 0) return false;
|
||||||
|
|
||||||
|
// Authenticated but no district — render an overlay and abort init.
|
||||||
|
console.warn('[App] Authenticated user has no district assigned; halting init.');
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'no-district-overlay';
|
||||||
|
overlay.setAttribute('role', 'alertdialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.style.cssText =
|
||||||
|
'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;' +
|
||||||
|
'justify-content:center;background:rgba(255,255,255,0.98);padding:24px;';
|
||||||
|
const name = session.full_name || session.username || 'You';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div style="max-width:480px;text-align:center;border:1px solid #e5e7eb;
|
||||||
|
border-radius:12px;padding:28px 24px;box-shadow:0 8px 24px rgba(0,0,0,0.08);
|
||||||
|
background:#fff;font-family:var(--font-body,sans-serif);">
|
||||||
|
<div style="font-size:42px;line-height:1;margin-bottom:12px;">🛑</div>
|
||||||
|
<h2 style="margin:0 0 12px;color:var(--primary,#005eb8);font-size:1.35rem;">
|
||||||
|
No district assigned
|
||||||
|
</h2>
|
||||||
|
<p style="margin:0 0 10px;color:#333;">
|
||||||
|
${escapeHtml(name)}, your user profile is not associated with any
|
||||||
|
district. LUPMIS2 cannot load the relevant map data without one.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 20px;color:#6b7280;font-size:0.95rem;">
|
||||||
|
Please contact the system administrator to have a district assigned
|
||||||
|
to your account.
|
||||||
|
</p>
|
||||||
|
<button type="button" id="no-district-portal-btn"
|
||||||
|
style="background:var(--primary,#005eb8);color:#fff;border:0;
|
||||||
|
border-radius:8px;padding:10px 18px;font-weight:600;cursor:pointer;">
|
||||||
|
Return to LUSPA portal
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.querySelector('#no-district-portal-btn')?.addEventListener('click', () => {
|
||||||
|
window.location.href = 'https://lupmis4luspa.org/';
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function initApp() {
|
async function initApp() {
|
||||||
console.log('[App] Initializing...');
|
console.log('[App] Initializing...');
|
||||||
|
|
||||||
|
// Pre-flight: authenticated user must have a district assigned.
|
||||||
|
if (showNoDistrictBlockerIfNeeded()) return;
|
||||||
|
|
||||||
// 1. Initialize PWA features (Service Worker, install prompt, offline detection)
|
// 1. Initialize PWA features (Service Worker, install prompt, offline detection)
|
||||||
await initPWA({
|
await initPWA({
|
||||||
installButton: '#install-btn',
|
installButton: '#install-btn',
|
||||||
@ -143,8 +216,20 @@ async function initApp() {
|
|||||||
// 'water': '💧', 'school': '🏫', 'health': '🏥',
|
// 'water': '💧', 'school': '🏫', 'health': '🏥',
|
||||||
// 'market': '🏪', 'default': '📍', 'other': '📌'
|
// 'market': '🏪', 'default': '📍', 'other': '📌'
|
||||||
|
|
||||||
|
// In iframe embed permit mode, install the postMessage bridge BEFORE the
|
||||||
|
// regular handlers so its outbound parcel:select / parcel:cleared events
|
||||||
|
// are wired up; the regular click/dblclick handlers below short-circuit in
|
||||||
|
// that mode (the bridge owns map interaction in the embed).
|
||||||
|
if (IS_EMBED_PERMIT) {
|
||||||
|
embedBridge = createEmbedBridge({ mapView, embedConfig: EMBED_CONFIG });
|
||||||
|
}
|
||||||
|
|
||||||
// Set up map click handler immediately after map creation
|
// Set up map click handler immediately after map creation
|
||||||
mapView.onClick((lon, lat, feature, evt) => {
|
mapView.onClick((lon, lat, feature, evt) => {
|
||||||
|
// Embed permit mode: the bridge handles parcel selection itself; the
|
||||||
|
// normal popup/add-location behaviour does not apply.
|
||||||
|
if (IS_EMBED_PERMIT) return;
|
||||||
|
|
||||||
console.log('[MapClick] Clicked at:', lon.toFixed(4), lat.toFixed(4));
|
console.log('[MapClick] Clicked at:', lon.toFixed(4), lat.toFixed(4));
|
||||||
console.log('[MapClick] currentMode =', currentMode);
|
console.log('[MapClick] currentMode =', currentMode);
|
||||||
|
|
||||||
@ -192,6 +277,8 @@ async function initApp() {
|
|||||||
// Set up double-click handler for overlay feature info
|
// Set up double-click handler for overlay feature info
|
||||||
// Uses '_layerType' property to distinguish zone features from other layers
|
// Uses '_layerType' property to distinguish zone features from other layers
|
||||||
mapView.onDblClick((lon, lat, feature, evt) => {
|
mapView.onDblClick((lon, lat, feature, evt) => {
|
||||||
|
// Embed permit mode shows no info popups (the host owns the UI).
|
||||||
|
if (IS_EMBED_PERMIT) return;
|
||||||
if (!feature) return;
|
if (!feature) return;
|
||||||
|
|
||||||
const layerType = feature.get('_layerType');
|
const layerType = feature.get('_layerType');
|
||||||
@ -329,6 +416,16 @@ async function initApp() {
|
|||||||
loadDistrictBoundary();
|
loadDistrictBoundary();
|
||||||
loadCollectorZones();
|
loadCollectorZones();
|
||||||
loadParcels();
|
loadParcels();
|
||||||
|
// In embed permit mode the parcels layer is the user's working surface,
|
||||||
|
// so make it visible immediately and hand the layer to the bridge so it
|
||||||
|
// can emit `ready` (and resolve any pending `set:selected` UPN) once the
|
||||||
|
// features arrive. loadParcels() runs its synchronous prologue (creating
|
||||||
|
// the layer and assigning the module-level reference) before returning
|
||||||
|
// its promise, so `parcelsLayer` is already set here.
|
||||||
|
if (IS_EMBED_PERMIT && embedBridge && parcelsLayer) {
|
||||||
|
parcelsLayer.setVisible(true);
|
||||||
|
embedBridge.attachParcelsLayer(parcelsLayer);
|
||||||
|
}
|
||||||
loadBuildingFootprints();
|
loadBuildingFootprints();
|
||||||
loadContoursHillshade();
|
loadContoursHillshade();
|
||||||
loadOSMRoads();
|
loadOSMRoads();
|
||||||
@ -1359,18 +1456,22 @@ function parcelsToGeoJSON(parcels) {
|
|||||||
seen.add(id);
|
seen.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer the GeoJSON geometry (sp_boundary) if available; fall back to WKT
|
// Geometry sources, in order:
|
||||||
|
// - API path: `geom` is a GeoJSON object, `boundary` is the WKT string
|
||||||
|
// - local cache: `geometry_wkt` is the WKT string
|
||||||
let geometry = null;
|
let geometry = null;
|
||||||
if (parcel.sp_boundary && parcel.sp_boundary.type && parcel.sp_boundary.coordinates) {
|
if (parcel.geom && parcel.geom.type && parcel.geom.coordinates) {
|
||||||
|
geometry = { type: parcel.geom.type, coordinates: parcel.geom.coordinates };
|
||||||
|
} else if (parcel.sp_boundary && parcel.sp_boundary.type && parcel.sp_boundary.coordinates) {
|
||||||
geometry = { type: parcel.sp_boundary.type, coordinates: parcel.sp_boundary.coordinates };
|
geometry = { type: parcel.sp_boundary.type, coordinates: parcel.sp_boundary.coordinates };
|
||||||
} else {
|
} else {
|
||||||
const wkt = parcel.boundary || parcel.polygon || parcel.geom || parcel.wkt;
|
const wkt = parcel.boundary || parcel.geometry_wkt || parcel.polygon || parcel.wkt;
|
||||||
geometry = parseWKT(wkt);
|
geometry = parseWKT(wkt);
|
||||||
}
|
}
|
||||||
if (!geometry) continue;
|
if (!geometry) continue;
|
||||||
|
|
||||||
// Collect all properties except bulky geometry fields
|
// Collect all properties except bulky geometry fields and local housekeeping.
|
||||||
const skipKeys = new Set(['polygon', 'boundary', 'geom', 'wkt', 'textboundary', 'sp_boundary']);
|
const skipKeys = new Set(['polygon', 'boundary', 'geom', 'geometry_wkt', 'wkt', 'textboundary', 'sp_boundary', 'fetched_at']);
|
||||||
const properties = { _layerType: 'parcel' };
|
const properties = { _layerType: 'parcel' };
|
||||||
for (const [key, value] of Object.entries(parcel)) {
|
for (const [key, value] of Object.entries(parcel)) {
|
||||||
if (skipKeys.has(key)) continue;
|
if (skipKeys.has(key)) continue;
|
||||||
@ -1407,7 +1508,9 @@ async function loadParcels() {
|
|||||||
// Create the Parcels layer immediately (empty) so it always appears
|
// Create the Parcels layer immediately (empty) so it always appears
|
||||||
// in the LayerSwitcher. Features will be added once data is available.
|
// in the LayerSwitcher. Features will be added once data is available.
|
||||||
const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
|
const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
|
||||||
const parcelsLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Parcels', parcelStyle, landUseGroup);
|
// Assigned to the module-level `parcelsLayer` so the iframe embed bridge
|
||||||
|
// can pick it up after loadParcels() returns from its sync prologue.
|
||||||
|
parcelsLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Parcels', parcelStyle, landUseGroup);
|
||||||
if (!parcelsLayer) {
|
if (!parcelsLayer) {
|
||||||
console.warn('[App] Could not create Parcels layer');
|
console.warn('[App] Could not create Parcels layer');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -13,11 +13,17 @@ DirectoryIndex index.php index.html
|
|||||||
SetHandler application/x-httpd-php
|
SetHandler application/x-httpd-php
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
# Common single-page-app behaviour: if a route doesn't map to a real file or
|
|
||||||
# directory, send the request to index.php so the PWA can handle it client-side.
|
|
||||||
# Comment out the next block if hash-based routing is preferred (no rewrites).
|
|
||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Clean URL for the iframe embed endpoint: /embed → embed.php
|
||||||
|
# Must come BEFORE the SPA fallback so /embed doesn't get routed to
|
||||||
|
# index.php. Query strings (?mode=permit&...) pass through automatically.
|
||||||
|
RewriteRule ^embed/?$ embed.php [L]
|
||||||
|
|
||||||
|
# Common single-page-app behaviour: if a route doesn't map to a real file
|
||||||
|
# or directory, send the request to index.php so the PWA can handle it
|
||||||
|
# client-side. Comment out this block if hash-based routing is preferred.
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteRule ^ index.php [L]
|
RewriteRule ^ index.php [L]
|
||||||
|
|||||||
157
public/embed.php
Normal file
157
public/embed.php
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* LUPMIS2 PWA — Iframe embed entry point
|
||||||
|
*
|
||||||
|
* Same SSO model as index.php (cookie validate → session → inject
|
||||||
|
* window.LUPMIS_SESSION), with three things added for the iframe channel
|
||||||
|
* defined by LUPMIS2_Reusable_Mapping_Concept.docx §3.2 / §4 and the
|
||||||
|
* Permit Map Integration document §2:
|
||||||
|
*
|
||||||
|
* 1. URL parameters (mode, lon, lat, zoom, upn, basemap, application_code)
|
||||||
|
* are parsed and exposed as `window.LUPMIS_EMBED` so the PWA can
|
||||||
|
* run in a slim, single-purpose mode (see main.js + src/embed-bridge.js).
|
||||||
|
* 2. `Content-Security-Policy: frame-ancestors` restricts who may embed
|
||||||
|
* this page — never `*`. The list comes from EMBED_ALLOWED_PARENTS
|
||||||
|
* below and should be tightened to the real permitting host once it
|
||||||
|
* is confirmed.
|
||||||
|
* 3. A body class hint (`embed embed-mode-permit`) is set client-side
|
||||||
|
* via the inline script so CSS can hide the unrelated chrome (navbar,
|
||||||
|
* bottom dock, account offcanvas, etc.).
|
||||||
|
*/
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SSO authentication — validate the cookie if we don't already have a session
|
||||||
|
// (same logic as index.php)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
||||||
|
$plainToken = $_COOKIE['sso_auth_token'];
|
||||||
|
$validate_url = 'https://lupmis4luspa.org/sso/validate?token=' . urlencode($plainToken);
|
||||||
|
|
||||||
|
$curl = curl_init();
|
||||||
|
curl_setopt_array($curl, [
|
||||||
|
CURLOPT_URL => $validate_url,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_ENCODING => "",
|
||||||
|
CURLOPT_MAXREDIRS => 10,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
|
||||||
|
CURLOPT_CUSTOMREQUEST => "GET",
|
||||||
|
CURLOPT_HTTPHEADER => [ "Content-Type: application/xml" ],
|
||||||
|
]);
|
||||||
|
$response = curl_exec($curl);
|
||||||
|
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($curl);
|
||||||
|
|
||||||
|
if ($httpCode === 200) {
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
if (
|
||||||
|
is_array($data)
|
||||||
|
&& isset($data['valid']) && $data['valid'] === true
|
||||||
|
&& isset($data['logged_in_user']) && is_array($data['logged_in_user'])
|
||||||
|
) {
|
||||||
|
foreach ($data['logged_in_user'] as $key => $value) {
|
||||||
|
$_SESSION[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setcookie('sso_auth_token', '', time() - 3600, '/', '.lupmis4luspa.org');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Production access guard — same rule as index.php
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
|
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
|
||||||
|
if ($isProduction && !isset($_SESSION['user_id'])) {
|
||||||
|
header('Location: https://lupmis4luspa.org/', true, 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Session payload (same shape as index.php)
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
$payload = [];
|
||||||
|
if (isset($_SESSION['user_id'])) {
|
||||||
|
$payload = [
|
||||||
|
'user_id' => $_SESSION['user_id'] ?? null,
|
||||||
|
'ua_id' => $_SESSION['ua_id'] ?? null,
|
||||||
|
'username' => $_SESSION['username'] ?? null,
|
||||||
|
'title' => $_SESSION['title'] ?? null,
|
||||||
|
'full_name' => $_SESSION['full_name'] ?? null,
|
||||||
|
'email' => $_SESSION['email'] ?? null,
|
||||||
|
'user_type' => $_SESSION['user_type'] ?? null,
|
||||||
|
'phone' => $_SESSION['phone'] ?? null,
|
||||||
|
'ua_position' => $_SESSION['ua_position'] ?? null,
|
||||||
|
'region_id' => $_SESSION['region_id'] ?? null,
|
||||||
|
'district_id' => $_SESSION['district_id'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Embed config — parse the contract's URL parameters (see Permit Map
|
||||||
|
// Integration doc §2.1). Strict whitelisting + type coercion keeps invalid
|
||||||
|
// input from reaching the PWA.
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
$validBasemaps = ['topo','osm','satellite','googlesat','carto-light','carto-dark','none'];
|
||||||
|
$validModes = ['permit'];
|
||||||
|
|
||||||
|
$mode = isset($_GET['mode']) ? (string)$_GET['mode'] : 'permit';
|
||||||
|
$basemap = isset($_GET['basemap']) ? (string)$_GET['basemap'] : null;
|
||||||
|
$upn = isset($_GET['upn']) ? (string)$_GET['upn'] : null;
|
||||||
|
$appCode = isset($_GET['application_code']) ? (string)$_GET['application_code'] : null;
|
||||||
|
|
||||||
|
$lon = isset($_GET['lon']) && is_numeric($_GET['lon']) ? (float)$_GET['lon'] : null;
|
||||||
|
$lat = isset($_GET['lat']) && is_numeric($_GET['lat']) ? (float)$_GET['lat'] : null;
|
||||||
|
$zoom = isset($_GET['zoom']) && is_numeric($_GET['zoom']) ? (int)$_GET['zoom'] : null;
|
||||||
|
|
||||||
|
$embed = [
|
||||||
|
'mode' => in_array($mode, $validModes, true) ? $mode : 'permit',
|
||||||
|
'lon' => $lon,
|
||||||
|
'lat' => $lat,
|
||||||
|
'zoom' => $zoom,
|
||||||
|
'upn' => $upn,
|
||||||
|
'basemap' => ($basemap && in_array($basemap, $validBasemaps, true)) ? $basemap : null,
|
||||||
|
'application_code' => $appCode,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Read the built index.html and inject the session + embed config
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
$indexPath = __DIR__ . '/index.html';
|
||||||
|
$html = is_readable($indexPath)
|
||||||
|
? file_get_contents($indexPath)
|
||||||
|
: '<!DOCTYPE html><html><body><h1>LUPMIS2 PWA</h1><p>index.html is missing.</p></body></html>';
|
||||||
|
|
||||||
|
$jsonFlags = JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
|
||||||
|
$sessionJson = json_encode($payload, $jsonFlags);
|
||||||
|
$embedJson = json_encode($embed, $jsonFlags);
|
||||||
|
$inject =
|
||||||
|
"<script>window.LUPMIS_SESSION = {$sessionJson};" .
|
||||||
|
"window.LUPMIS_EMBED = {$embedJson};" .
|
||||||
|
// Set the body class as early as possible so CSS rules apply on first paint.
|
||||||
|
"document.addEventListener('DOMContentLoaded',function(){" .
|
||||||
|
"document.body.classList.add('embed','embed-mode-' + (window.LUPMIS_EMBED.mode || 'permit'));" .
|
||||||
|
"});</script>";
|
||||||
|
|
||||||
|
$html = preg_replace('/<head\b[^>]*>/i', '$0' . "\n " . $inject, $html, 1);
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Headers — frame-ancestors restricts who may embed; tighten this list once
|
||||||
|
// the real permitting host is confirmed. NEVER use `frame-ancestors *`.
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
$EMBED_ALLOWED_PARENTS = [
|
||||||
|
'https://permits.lupmis4luspa.org',
|
||||||
|
// Add local dev parents here if needed, e.g. 'http://localhost:8000'.
|
||||||
|
];
|
||||||
|
$frameAncestors = "'self' " . implode(' ', $EMBED_ALLOWED_PARENTS);
|
||||||
|
|
||||||
|
header("Content-Security-Policy: frame-ancestors {$frameAncestors}");
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store, must-revalidate');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||||
|
|
||||||
|
echo $html;
|
||||||
@ -65,6 +65,21 @@ if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Production access guard
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// On the public production host, calling this file without a valid SSO session
|
||||||
|
// is not allowed — bounce the visitor to the central LUSPA login portal. On
|
||||||
|
// local development (any host that is not *.lupmis4luspa.org, e.g. localhost,
|
||||||
|
// 127.0.0.1, a developer's .local hostname) the guard is bypassed so the PHP
|
||||||
|
// entry point can still be exercised directly during testing.
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
|
$isProduction = (bool) preg_match('/(^|\.)lupmis4luspa\.org$/i', $host);
|
||||||
|
if ($isProduction && !isset($_SESSION['user_id'])) {
|
||||||
|
header('Location: https://lupmis4luspa.org/', true, 302);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
// Build the payload exposed to the PWA as window.LUPMIS_SESSION
|
// Build the payload exposed to the PWA as window.LUPMIS_SESSION
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -29,7 +29,11 @@
|
|||||||
// mobile drawing-toolbar wrap, base-map "None" option, and the Safari
|
// mobile drawing-toolbar wrap, base-map "None" option, and the Safari
|
||||||
// 100svh dock fix. New hashed bundle + updated shell — bump to evict the
|
// 100svh dock fix. New hashed bundle + updated shell — bump to evict the
|
||||||
// old module/shell caches.
|
// old module/shell caches.
|
||||||
const CACHE_VERSION = 'v8';
|
// v9: Iframe embed endpoint (/embed via public/embed.php) + postMessage bridge
|
||||||
|
// for the permitting integration; lu_parcels structural refactor in the
|
||||||
|
// local DB; production access guard + no-district overlay; LayerSwitcher
|
||||||
|
// ordering fix. New shell + hashed bundle.
|
||||||
|
const CACHE_VERSION = 'v9';
|
||||||
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
||||||
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
||||||
const API_CACHE = `api-${CACHE_VERSION}`;
|
const API_CACHE = `api-${CACHE_VERSION}`;
|
||||||
|
|||||||
@ -50,10 +50,13 @@ export class MapTools {
|
|||||||
zIndex: 99,
|
zIndex: 99,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Insert both layers just before the last layer (Overlays group)
|
// Insert both layers just before the Overlays group so the LayerSwitcher
|
||||||
// so the LayerSwitcher order becomes: Overlays > Measurements > Markers > Base Maps
|
// order becomes: Overlays > Measurements > Markers > Base Maps. Locate the
|
||||||
|
// Overlays group by title rather than assuming it is the last layer —
|
||||||
|
// other layers (e.g. the GPS trail/position layers) may sit on top of it.
|
||||||
const layers = this.map.getLayers();
|
const layers = this.map.getLayers();
|
||||||
const overlayIdx = layers.getLength() - 1; // Overlays is the last layer
|
let overlayIdx = layers.getArray().findIndex((l) => l.get('title') === 'Overlays');
|
||||||
|
if (overlayIdx < 0) overlayIdx = layers.getLength();
|
||||||
layers.insertAt(overlayIdx, this.drawLayer);
|
layers.insertAt(overlayIdx, this.drawLayer);
|
||||||
layers.insertAt(overlayIdx, this.measureLayer);
|
layers.insertAt(overlayIdx, this.measureLayer);
|
||||||
|
|
||||||
|
|||||||
@ -370,11 +370,14 @@ export class MapView {
|
|||||||
title: 'Drawings',
|
title: 'Drawings',
|
||||||
layers: [this.drawingsLayer],
|
layers: [this.drawingsLayer],
|
||||||
});
|
});
|
||||||
// Insert as a top-level map layer just before the Overlays group
|
// Insert as a top-level map layer just before the Overlays group so the
|
||||||
// so the LayerSwitcher order is: Overlays > Drawings > Measurements > Markers > Base Maps
|
// LayerSwitcher order is: Overlays > Drawings > Measurements > Markers > Base Maps.
|
||||||
|
// Find Overlays by reference rather than assuming it is the last layer —
|
||||||
|
// other layers (e.g. the GPS trail/position layers added by
|
||||||
|
// _initGpsRendering) may sit on top of it.
|
||||||
const mapLayers = this.map.getLayers();
|
const mapLayers = this.map.getLayers();
|
||||||
const overlayIdx = mapLayers.getLength() - 1; // Overlays is the last layer
|
const overlayIdx = mapLayers.getArray().indexOf(this.overlayGroup);
|
||||||
mapLayers.insertAt(overlayIdx, this._drawingsGroup);
|
mapLayers.insertAt(overlayIdx >= 0 ? overlayIdx : mapLayers.getLength(), this._drawingsGroup);
|
||||||
|
|
||||||
// 2. Create a Select interaction that works on ALL vector layers.
|
// 2. Create a Select interaction that works on ALL vector layers.
|
||||||
// It starts INACTIVE so it doesn't steal clicks from normal handlers.
|
// It starts INACTIVE so it doesn't steal clicks from normal handlers.
|
||||||
|
|||||||
166
src/database.js
166
src/database.js
@ -164,27 +164,58 @@ export async function initSchema() {
|
|||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Create parcels table for caching parcel data
|
// Create parcels table — mirrors the server's spatial.lu_parcels structure
|
||||||
// status: 'verified' = from API, 'new' = drawn locally, needs verification
|
// (land-use parcels). Attribute columns match the server 1:1 so the local
|
||||||
|
// data viewer shows real fields rather than a JSON blob.
|
||||||
|
// status — local-only flag: 'verified' = downloaded from the API,
|
||||||
|
// 'new' = drawn locally and pending server verification.
|
||||||
|
// fetched_at — local-only cache timestamp.
|
||||||
|
// Migrate older databases that used the previous JSON-blob schema
|
||||||
|
// (id, geometry_wkt, properties, status, fetched_at): if the new `upn`
|
||||||
|
// column is missing, drop the cached table so it is recreated with the
|
||||||
|
// lu_parcels columns. Cached server parcels re-download on next load.
|
||||||
console.log('[Database] Creating parcels table...');
|
console.log('[Database] Creating parcels table...');
|
||||||
|
try {
|
||||||
|
const cols = await sql`PRAGMA table_info(parcels)`;
|
||||||
|
if (cols.length > 0 && !cols.some((c) => c.name === 'upn')) {
|
||||||
|
console.log('[Database] Migrating parcels table to lu_parcels structure (dropping old cache)...');
|
||||||
|
await sql`DROP TABLE parcels`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Database] parcels migration check failed:', e);
|
||||||
|
}
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS parcels (
|
CREATE TABLE IF NOT EXISTS parcels (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
|
upn TEXT,
|
||||||
|
style INTEGER,
|
||||||
|
landuse TEXT,
|
||||||
|
zone_code TEXT,
|
||||||
|
zone_name TEXT,
|
||||||
|
sector TEXT,
|
||||||
|
block TEXT,
|
||||||
|
parcel_no TEXT,
|
||||||
|
prop_no TEXT,
|
||||||
|
st_name TEXT,
|
||||||
|
prop_add TEXT,
|
||||||
|
fac_name TEXT,
|
||||||
|
min_height INTEGER,
|
||||||
|
max_height INTEGER,
|
||||||
|
eff_date TEXT,
|
||||||
|
lp_name TEXT,
|
||||||
|
locality TEXT,
|
||||||
|
mmda TEXT,
|
||||||
|
last_update TEXT,
|
||||||
|
remarks TEXT,
|
||||||
geometry_wkt TEXT,
|
geometry_wkt TEXT,
|
||||||
properties TEXT,
|
created_at TEXT,
|
||||||
|
updated_at TEXT,
|
||||||
|
districtid INTEGER,
|
||||||
status TEXT DEFAULT 'verified',
|
status TEXT DEFAULT 'verified',
|
||||||
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
|
fetched_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Migrate: add status column if it doesn't exist (for existing databases)
|
|
||||||
try {
|
|
||||||
await sql`SELECT status FROM parcels LIMIT 1`;
|
|
||||||
} catch {
|
|
||||||
console.log('[Database] Adding status column to parcels table...');
|
|
||||||
await sql`ALTER TABLE parcels ADD COLUMN status TEXT DEFAULT 'verified'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create building_footprints table for caching footprint data
|
// Create building_footprints table for caching footprint data
|
||||||
console.log('[Database] Creating building_footprints table...');
|
console.log('[Database] Creating building_footprints table...');
|
||||||
await sql`
|
await sql`
|
||||||
@ -586,30 +617,59 @@ export async function getLocalCollectorZones() {
|
|||||||
// Parcels
|
// Parcels
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Coerce a value for an INTEGER column; '' / null / undefined / NaN → null. */
|
||||||
|
function numOrNull(v) {
|
||||||
|
if (v === '' || v === null || v === undefined) return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isNaN(n) ? null : n;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save parcels to the local table.
|
* Save parcels to the local table (mirrors spatial.lu_parcels).
|
||||||
* Replaces all existing rows.
|
* Replaces all server-cached rows. Each API field maps to its own column.
|
||||||
|
* Geometry is taken from the server `geom` field (WKT, EPSG:4326).
|
||||||
*
|
*
|
||||||
* @param {Array} parcels - Array of parcel objects from the API
|
* @param {Array} parcels - Array of parcel objects from the API
|
||||||
*/
|
*/
|
||||||
export async function saveParcels(parcels) {
|
export async function saveParcels(parcels) {
|
||||||
try {
|
try {
|
||||||
|
// Wrap the bulk load in a single transaction — with ~25k parcels, per-row
|
||||||
|
// auto-commits on OPFS would be prohibitively slow.
|
||||||
|
await sql`BEGIN`;
|
||||||
await sql`DELETE FROM parcels`;
|
await sql`DELETE FROM parcels`;
|
||||||
let saved = 0;
|
let saved = 0;
|
||||||
for (const p of parcels) {
|
for (const p of parcels) {
|
||||||
const id = p.id || p.parcelid || p.parcel_id || null;
|
const id = p.id ?? p.parcelid ?? p.parcel_id ?? null;
|
||||||
if (id == null) continue; // skip rows without a usable ID
|
if (id == null) continue; // skip rows without a usable ID
|
||||||
const props = JSON.stringify(p);
|
// Geometry must be a WKT *string* for the geometry_wkt TEXT column.
|
||||||
// API field names: 'boundary' (WKT), 'polygon', 'geom', 'wkt'
|
// The API sends WKT in `boundary` and a GeoJSON *object* in `geom`, so
|
||||||
const wkt = p.boundary || p.polygon || p.geom || p.wkt || '';
|
// prefer the string fields and only accept `geom` when it is a string.
|
||||||
|
const wkt = p.boundary || p.geometry_wkt || p.polygon || p.wkt
|
||||||
|
|| (typeof p.geom === 'string' ? p.geom : '');
|
||||||
await sql`
|
await sql`
|
||||||
INSERT OR REPLACE INTO parcels (id, geometry_wkt, properties, fetched_at)
|
INSERT OR REPLACE INTO parcels (
|
||||||
VALUES (${id}, ${wkt}, ${props}, CURRENT_TIMESTAMP)
|
id, upn, style, landuse, zone_code, zone_name, sector, block, parcel_no,
|
||||||
|
prop_no, st_name, prop_add, fac_name, min_height, max_height, eff_date,
|
||||||
|
lp_name, locality, mmda, last_update, remarks, geometry_wkt,
|
||||||
|
created_at, updated_at, districtid, status, fetched_at
|
||||||
|
) VALUES (
|
||||||
|
${id}, ${p.upn ?? null}, ${numOrNull(p.style)}, ${p.landuse ?? null},
|
||||||
|
${p.zone_code ?? null}, ${p.zone_name ?? null}, ${p.sector ?? null},
|
||||||
|
${p.block ?? null}, ${p.parcel_no ?? null}, ${p.prop_no ?? null},
|
||||||
|
${p.st_name ?? null}, ${p.prop_add ?? null}, ${p.fac_name ?? null},
|
||||||
|
${numOrNull(p.min_height)}, ${numOrNull(p.max_height)}, ${p.eff_date ?? null},
|
||||||
|
${p.lp_name ?? null}, ${p.locality ?? null}, ${p.mmda ?? null},
|
||||||
|
${p.last_update ?? null}, ${p.remarks ?? null}, ${wkt},
|
||||||
|
${p.created_at ?? null}, ${p.updated_at ?? null}, ${numOrNull(p.districtid)},
|
||||||
|
'verified', CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
`;
|
`;
|
||||||
saved++;
|
saved++;
|
||||||
}
|
}
|
||||||
console.log('[Database] ✓ Saved', saved, 'parcels (from', parcels.length, 'rows,', parcels.length - saved, 'duplicates replaced)');
|
await sql`COMMIT`;
|
||||||
|
console.log('[Database] ✓ Saved', saved, 'parcels (from', parcels.length, 'rows,', parcels.length - saved, 'skipped/replaced)');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
try { await sql`ROLLBACK`; } catch { /* no active txn */ }
|
||||||
console.error('[Database] ✗ Failed to save parcels:', error);
|
console.error('[Database] ✗ Failed to save parcels:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -617,13 +677,14 @@ export async function saveParcels(parcels) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load all cached parcels from the local table.
|
* Load all cached parcels from the local table.
|
||||||
* @returns {Promise<Array|null>} Array of parcel objects, or null if empty
|
* Each row is a plain object keyed by column name (incl. geometry_wkt).
|
||||||
|
* @returns {Promise<Array|null>} Array of parcel rows, or null if empty
|
||||||
*/
|
*/
|
||||||
export async function getLocalParcels() {
|
export async function getLocalParcels() {
|
||||||
try {
|
try {
|
||||||
const rows = await sql`SELECT properties FROM parcels ORDER BY id`;
|
const rows = await sql`SELECT * FROM parcels ORDER BY id`;
|
||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
return rows.map(r => JSON.parse(r.properties));
|
return rows;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Database] ✗ Failed to read local parcels:', error);
|
console.error('[Database] ✗ Failed to read local parcels:', error);
|
||||||
return null;
|
return null;
|
||||||
@ -631,16 +692,40 @@ export async function getLocalParcels() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a single parcel's properties in the local table.
|
* Update a single parcel's attribute columns in the local table.
|
||||||
* Only the properties JSON blob is updated; geometry stays unchanged.
|
* Geometry, id, created_at and status are left unchanged; updated_at is bumped.
|
||||||
*
|
*
|
||||||
* @param {number|string} parcelId - The parcel id
|
* @param {number|string} parcelId - The parcel id
|
||||||
* @param {Object} updatedProps - Full updated properties object
|
* @param {Object} p - Updated attribute values (keys = lu_parcels columns)
|
||||||
*/
|
*/
|
||||||
export async function updateParcel(parcelId, updatedProps) {
|
export async function updateParcel(parcelId, p) {
|
||||||
try {
|
try {
|
||||||
const props = JSON.stringify(updatedProps);
|
await sql`
|
||||||
await sql`UPDATE parcels SET properties = ${props} WHERE id = ${parcelId}`;
|
UPDATE parcels SET
|
||||||
|
upn = ${p.upn ?? null},
|
||||||
|
style = ${numOrNull(p.style)},
|
||||||
|
landuse = ${p.landuse ?? null},
|
||||||
|
zone_code = ${p.zone_code ?? null},
|
||||||
|
zone_name = ${p.zone_name ?? null},
|
||||||
|
sector = ${p.sector ?? null},
|
||||||
|
block = ${p.block ?? null},
|
||||||
|
parcel_no = ${p.parcel_no ?? null},
|
||||||
|
prop_no = ${p.prop_no ?? null},
|
||||||
|
st_name = ${p.st_name ?? null},
|
||||||
|
prop_add = ${p.prop_add ?? null},
|
||||||
|
fac_name = ${p.fac_name ?? null},
|
||||||
|
min_height = ${numOrNull(p.min_height)},
|
||||||
|
max_height = ${numOrNull(p.max_height)},
|
||||||
|
eff_date = ${p.eff_date ?? null},
|
||||||
|
lp_name = ${p.lp_name ?? null},
|
||||||
|
locality = ${p.locality ?? null},
|
||||||
|
mmda = ${p.mmda ?? null},
|
||||||
|
last_update = ${p.last_update ?? null},
|
||||||
|
remarks = ${p.remarks ?? null},
|
||||||
|
districtid = ${numOrNull(p.districtid)},
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ${parcelId}
|
||||||
|
`;
|
||||||
console.log('[Database] ✓ Parcel updated:', parcelId);
|
console.log('[Database] ✓ Parcel updated:', parcelId);
|
||||||
broadcastChange('parcels', 'UPDATE', parcelId);
|
broadcastChange('parcels', 'UPDATE', parcelId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -654,15 +739,28 @@ export async function updateParcel(parcelId, updatedProps) {
|
|||||||
* The parcel is tagged with status='new' to indicate it needs verification.
|
* The parcel is tagged with status='new' to indicate it needs verification.
|
||||||
*
|
*
|
||||||
* @param {string} geometryWkt - WKT geometry string (EPSG:4326)
|
* @param {string} geometryWkt - WKT geometry string (EPSG:4326)
|
||||||
* @param {Object} properties - Attribute properties from the form
|
* @param {Object} p - Attribute values from the form (keys = lu_parcels columns)
|
||||||
* @returns {Promise<{id: number}>} The new row id
|
* @returns {Promise<{id: number}>} The new row id
|
||||||
*/
|
*/
|
||||||
export async function insertNewParcel(geometryWkt, properties) {
|
export async function insertNewParcel(geometryWkt, p = {}) {
|
||||||
try {
|
try {
|
||||||
const props = JSON.stringify(properties);
|
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO parcels (id, geometry_wkt, properties, status, fetched_at)
|
INSERT INTO parcels (
|
||||||
VALUES (NULL, ${geometryWkt}, ${props}, 'new', CURRENT_TIMESTAMP)
|
id, upn, style, landuse, zone_code, zone_name, sector, block, parcel_no,
|
||||||
|
prop_no, st_name, prop_add, fac_name, min_height, max_height, eff_date,
|
||||||
|
lp_name, locality, mmda, last_update, remarks, geometry_wkt,
|
||||||
|
created_at, updated_at, districtid, status, fetched_at
|
||||||
|
) VALUES (
|
||||||
|
NULL, ${p.upn ?? null}, ${numOrNull(p.style)}, ${p.landuse ?? null},
|
||||||
|
${p.zone_code ?? null}, ${p.zone_name ?? null}, ${p.sector ?? null},
|
||||||
|
${p.block ?? null}, ${p.parcel_no ?? null}, ${p.prop_no ?? null},
|
||||||
|
${p.st_name ?? null}, ${p.prop_add ?? null}, ${p.fac_name ?? null},
|
||||||
|
${numOrNull(p.min_height)}, ${numOrNull(p.max_height)}, ${p.eff_date ?? null},
|
||||||
|
${p.lp_name ?? null}, ${p.locality ?? null}, ${p.mmda ?? null},
|
||||||
|
${p.last_update ?? null}, ${p.remarks ?? null}, ${geometryWkt},
|
||||||
|
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ${numOrNull(p.districtid)},
|
||||||
|
'new', CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
`;
|
`;
|
||||||
const idResult = await sql`SELECT last_insert_rowid() as id`;
|
const idResult = await sql`SELECT last_insert_rowid() as id`;
|
||||||
const newId = idResult[0]?.id;
|
const newId = idResult[0]?.id;
|
||||||
|
|||||||
262
src/embed-bridge.js
Normal file
262
src/embed-bridge.js
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* LUPMIS2 — iframe embed bridge
|
||||||
|
*
|
||||||
|
* Implements the postMessage contract defined in
|
||||||
|
* LUPMIS2_Permit_Map_Integration.docx §2
|
||||||
|
* which itself follows the iframe channel from
|
||||||
|
* LUPMIS2_Reusable_Mapping_Concept.docx §3.2 / §4.
|
||||||
|
*
|
||||||
|
* Outbound (embed → host):
|
||||||
|
* { type: 'ready' }
|
||||||
|
* { type: 'parcel:select', upn, parcel_id, lon, lat,
|
||||||
|
* zone_code, zone_name, landuse,
|
||||||
|
* min_height, max_height }
|
||||||
|
* { type: 'parcel:cleared' }
|
||||||
|
* { type: 'error', code, message }
|
||||||
|
*
|
||||||
|
* Inbound (host → embed):
|
||||||
|
* { type: 'set:view', lon, lat, zoom }
|
||||||
|
* { type: 'set:selected', upn }
|
||||||
|
* { type: 'clear:selected' }
|
||||||
|
* { type: 'set:basemap', key }
|
||||||
|
*
|
||||||
|
* The bridge is framework-agnostic apart from the OpenLayers imports — it
|
||||||
|
* lives one level above the proposed `map-core` library so once that library
|
||||||
|
* is extracted (concept §3.1) this file can be lifted into it unchanged.
|
||||||
|
*
|
||||||
|
* Note on security: outbound messages are sent with target origin `*` because
|
||||||
|
* the embed cannot know its parent's origin in advance and the payload is
|
||||||
|
* non-sensitive (selection metadata only). The HOST is expected to verify
|
||||||
|
* `event.origin === '<embed origin>'` before trusting any message, as
|
||||||
|
* documented in §2.2 of the integration doc. Inbound commands are
|
||||||
|
* type-checked against a strict whitelist before being acted on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fromLonLat, toLonLat } from 'ol/proj.js';
|
||||||
|
import { getCenter } from 'ol/extent.js';
|
||||||
|
import VectorLayer from 'ol/layer/Vector.js';
|
||||||
|
import VectorSource from 'ol/source/Vector.js';
|
||||||
|
import { Style, Stroke, Fill } from 'ol/style.js';
|
||||||
|
|
||||||
|
const KNOWN_COMMANDS = new Set([
|
||||||
|
'set:view',
|
||||||
|
'set:selected',
|
||||||
|
'clear:selected',
|
||||||
|
'set:basemap',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and install the embed bridge.
|
||||||
|
*
|
||||||
|
* @param {Object} opts
|
||||||
|
* @param {Object} opts.mapView — the MapView instance (uses onClick,
|
||||||
|
* getMap, setBaseMap).
|
||||||
|
* @param {Object} opts.embedConfig — window.LUPMIS_EMBED contents.
|
||||||
|
* @returns {{ attachParcelsLayer: (layer) => void, emitError: (code, msg) => void }}
|
||||||
|
*/
|
||||||
|
export function createEmbedBridge({ mapView, embedConfig }) {
|
||||||
|
const map = mapView.getMap();
|
||||||
|
const parent = (window.parent && window.parent !== window) ? window.parent : null;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Highlight layer — visual cue for the currently-selected parcel.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const highlightSource = new VectorSource();
|
||||||
|
const highlightLayer = new VectorLayer({
|
||||||
|
source: highlightSource,
|
||||||
|
zIndex: 9999,
|
||||||
|
style: new Style({
|
||||||
|
stroke: new Stroke({ color: '#f97316', width: 3 }),
|
||||||
|
fill: new Fill({ color: 'rgba(249,115,22,0.18)' }),
|
||||||
|
}),
|
||||||
|
properties: {
|
||||||
|
title: 'Permit selection',
|
||||||
|
displayInLayerSwitcher: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
map.addLayer(highlightLayer);
|
||||||
|
|
||||||
|
let parcelsLayer = null;
|
||||||
|
let pendingSelectUpn = embedConfig?.upn ? String(embedConfig.upn) : null;
|
||||||
|
let readyEmitted = false;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Outbound
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
function send(message) {
|
||||||
|
if (!parent) {
|
||||||
|
console.warn('[embed-bridge] No parent window — would have sent:', message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
parent.postMessage(message, '*');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[embed-bridge] postMessage failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitError(code, message) {
|
||||||
|
send({ type: 'error', code, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitReady() {
|
||||||
|
if (readyEmitted) return;
|
||||||
|
readyEmitted = true;
|
||||||
|
send({ type: 'ready' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a `parcel:select` payload from a feature and (optionally) the click point. */
|
||||||
|
function parcelPayload(feature, lon, lat) {
|
||||||
|
const p = feature.getProperties();
|
||||||
|
let outLon = lon, outLat = lat;
|
||||||
|
if (outLon == null || outLat == null) {
|
||||||
|
const ext = feature.getGeometry()?.getExtent();
|
||||||
|
if (ext) {
|
||||||
|
const [cx, cy] = toLonLat(getCenter(ext));
|
||||||
|
outLon = cx; outLat = cy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'parcel:select',
|
||||||
|
upn: p.upn ?? null,
|
||||||
|
parcel_id: p.id ?? null,
|
||||||
|
lon: outLon ?? null,
|
||||||
|
lat: outLat ?? null,
|
||||||
|
zone_code: p.zone_code ?? null,
|
||||||
|
zone_name: p.zone_name ?? null,
|
||||||
|
landuse: p.landuse ?? null,
|
||||||
|
min_height: p.min_height ?? null,
|
||||||
|
max_height: p.max_height ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightFeature(feature) {
|
||||||
|
highlightSource.clear();
|
||||||
|
if (feature) {
|
||||||
|
const clone = feature.clone();
|
||||||
|
highlightSource.addFeature(clone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Click → parcel:select / parcel:cleared
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
mapView.onClick((lon, lat, _markerFeature, evt) => {
|
||||||
|
let parcelFeature = null;
|
||||||
|
map.forEachFeatureAtPixel(evt.pixel, (f) => {
|
||||||
|
if (f.get('_layerType') === 'parcel') {
|
||||||
|
parcelFeature = f;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (parcelFeature) {
|
||||||
|
highlightFeature(parcelFeature);
|
||||||
|
send(parcelPayload(parcelFeature, lon, lat));
|
||||||
|
} else {
|
||||||
|
highlightFeature(null);
|
||||||
|
send({ type: 'parcel:cleared' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Inbound commands
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
const msg = event.data;
|
||||||
|
if (!msg || typeof msg !== 'object' || !KNOWN_COMMANDS.has(msg.type)) return;
|
||||||
|
try {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'set:view': {
|
||||||
|
if (typeof msg.lon === 'number' && typeof msg.lat === 'number') {
|
||||||
|
const view = map.getView();
|
||||||
|
view.setCenter(fromLonLat([msg.lon, msg.lat]));
|
||||||
|
if (typeof msg.zoom === 'number') view.setZoom(msg.zoom);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'set:selected':
|
||||||
|
if (msg.upn) selectByUpn(String(msg.upn));
|
||||||
|
break;
|
||||||
|
case 'clear:selected':
|
||||||
|
highlightFeature(null);
|
||||||
|
pendingSelectUpn = null;
|
||||||
|
break;
|
||||||
|
case 'set:basemap':
|
||||||
|
if (msg.key && typeof mapView.setBaseMap === 'function') {
|
||||||
|
mapView.setBaseMap(msg.key);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
emitError('COMMAND_FAILED', `Failed to handle ${msg.type}: ${e.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the parcel with the given UPN, highlight it, fit the view to it,
|
||||||
|
* and emit a synthesized parcel:select so the host receives the metadata.
|
||||||
|
* If the parcels haven't finished loading yet, the UPN is queued and the
|
||||||
|
* lookup is retried as features stream in.
|
||||||
|
*/
|
||||||
|
function selectByUpn(upn) {
|
||||||
|
if (!parcelsLayer) { pendingSelectUpn = upn; return; }
|
||||||
|
const features = parcelsLayer.getSource().getFeatures();
|
||||||
|
const feature = features.find((f) => String(f.get('upn') ?? '') === upn);
|
||||||
|
if (!feature) { pendingSelectUpn = upn; return; }
|
||||||
|
|
||||||
|
pendingSelectUpn = null;
|
||||||
|
highlightFeature(feature);
|
||||||
|
const ext = feature.getGeometry()?.getExtent();
|
||||||
|
if (ext) {
|
||||||
|
map.getView().fit(ext, { padding: [50, 50, 50, 50], duration: 400, maxZoom: 17 });
|
||||||
|
}
|
||||||
|
send(parcelPayload(feature, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Parcels attached → emit `ready`, drain any pending set:selected
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
function attachParcelsLayer(layer) {
|
||||||
|
parcelsLayer = layer;
|
||||||
|
const source = layer.getSource();
|
||||||
|
|
||||||
|
const drain = () => {
|
||||||
|
// Microtask hop so a batch addFeatures() finishes before we react.
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (pendingSelectUpn) selectByUpn(pendingSelectUpn);
|
||||||
|
emitReady();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (source.getFeatures().length > 0) {
|
||||||
|
drain();
|
||||||
|
} else {
|
||||||
|
// Stay subscribed: parcels may arrive in waves (cache then API refresh),
|
||||||
|
// and a pending UPN may only resolve after the second wave.
|
||||||
|
let scheduled = false;
|
||||||
|
source.on('addfeature', () => {
|
||||||
|
if (scheduled) return;
|
||||||
|
scheduled = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
scheduled = false;
|
||||||
|
if (pendingSelectUpn) selectByUpn(pendingSelectUpn);
|
||||||
|
emitReady();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Apply initial config: basemap + view (UPN is handled after parcels load)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
if (embedConfig?.basemap && typeof mapView.setBaseMap === 'function') {
|
||||||
|
mapView.setBaseMap(embedConfig.basemap);
|
||||||
|
}
|
||||||
|
if (typeof embedConfig?.lon === 'number' && typeof embedConfig?.lat === 'number') {
|
||||||
|
const view = map.getView();
|
||||||
|
view.setCenter(fromLonLat([embedConfig.lon, embedConfig.lat]));
|
||||||
|
view.setZoom(typeof embedConfig?.zoom === 'number' ? embedConfig.zoom : 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { attachParcelsLayer, emitError };
|
||||||
|
}
|
||||||
@ -32,16 +32,27 @@ const FALLBACK_DISTRICT_ID = '1';
|
|||||||
const API_TOKEN = '1c46538c712e9b5b';
|
const API_TOKEN = '1c46538c712e9b5b';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the authenticated user's district_id, or the dev fallback.
|
* Returns the authenticated user's district_id.
|
||||||
* The getter runs on each spread of API_CREDENTIALS, so changing the
|
*
|
||||||
* session at runtime (rare but possible) takes effect immediately.
|
* - No SSO session at all (window.LUPMIS_SESSION undefined): we're in local
|
||||||
|
* development → fall back to the hard-coded test district.
|
||||||
|
* - Session present but no district_id: the user is authenticated but not
|
||||||
|
* assigned to any district → return null. The bootstrap in main.js detects
|
||||||
|
* this case BEFORE any API call and shows a blocking message; this null
|
||||||
|
* is defence-in-depth so we never silently send district_id=1 for an
|
||||||
|
* authenticated user.
|
||||||
|
*
|
||||||
|
* The getter runs on each spread of API_CREDENTIALS, so a session change at
|
||||||
|
* runtime takes effect immediately.
|
||||||
*/
|
*/
|
||||||
function resolveDistrictId() {
|
function resolveDistrictId() {
|
||||||
try {
|
try {
|
||||||
const id = (typeof window !== 'undefined') && window.LUPMIS_SESSION?.district_id;
|
if (typeof window === 'undefined') return FALLBACK_DISTRICT_ID;
|
||||||
if (id !== null && id !== undefined && String(id).length > 0) {
|
const session = window.LUPMIS_SESSION;
|
||||||
return String(id);
|
if (!session || typeof session !== 'object') return FALLBACK_DISTRICT_ID;
|
||||||
}
|
const id = session.district_id;
|
||||||
|
if (id === null || id === undefined || String(id).length === 0) return null;
|
||||||
|
return String(id);
|
||||||
} catch { /* no-op */ }
|
} catch { /* no-op */ }
|
||||||
return FALLBACK_DISTRICT_ID;
|
return FALLBACK_DISTRICT_ID;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user