There were two ways to put a point on the map — the Add Location dock button
and Draw > Point — and they produced different things: one a Location record,
the other a sketch in the Drawings layer. Officers had to know which was which.
Draw > Point now raises the Add Location form at the coordinate placed, and the
Add Location button and its mode are gone.
The sketch is taken back out again. The marker created by the form is the
record, so leaving the sketch would show the same place twice, and it would
linger if the officer cancelled the form. That removal needs care: ol adds the
sketch to the source *after* drawend returns, so the add and the removal
straddle a tick and undo recording has to be suppressed across both. Suppressing
only the removal leaves the add on the stack, and Undo then resurrects a point
the officer never kept. Verified that the stack is untouched and that recording
is switched back on afterwards.
Tapping a marker to see its details used to require Add Location mode. With that
mode gone it now works in the neutral mode, where a click previously did nothing
at all. Clicks on empty space still do nothing — a stray tap should not open a
form.
The dock keeps its six remaining buttons. Below 576px they divide the width
evenly rather than relying on a 60px minimum each, which no longer fitted once
padded (6 x 60 + 32 = 392px against 390px on common iPhones). Checked from 360px
up.
Service Worker v19 -> v20.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Now that the strip paints on iPhone, it turned out to sit at the very bottom
edge — under the right-edge control stack and clipped by the dock.
The strip spans the full width, so it has to clear that stack rather than sit
beside it:
My Location bottom: 90px + 44px tall -> top at 134px
Base map switcher bottom: 144px + 44px tall -> top at 188px
so bottom: 196px, 8px above the topmost of them. The arithmetic is written into
the rule, with a pointer to .ls-locate-toggle and the LayerSwitcher button in
layerswitcher.css, since a change to either silently reintroduces the overlap.
Landscape still fits: the map is ~270px tall there and the strip is one row at
that width, so 196 + 52 = 248px.
Verified against stand-ins for both buttons at phone and tablet widths: no
overlap in either the one-row or the wrapped two-row layout.
Service Worker v18 -> v19.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The toolbar still did not paint on iPhone in portrait. v17 removed the
backdrop-filter from the scrolling strip but kept the horizontal scroll, and
that was the wrong half to keep: on iOS a scroll container is the fragile part,
with or without a blur.
The strip goes back to wrapping onto two rows — the layout that shipped in v8
and worked on these phones for months, before the rail replaced it in v15. That
was a mistake of mine: I swapped a device-tested layout for a scrolling one on
the strength of desktop testing alone. It now has no overflow, no blur and an
opaque background, and pairs `left` with `width` rather than `right`, so there
is nothing exotic left for iOS to disagree with. A comment on the block says so,
because the scrolling version reads as the tidier solution and someone will be
tempted again.
Group headings become hairline dividers on the strip rather than disappearing,
so the Draw / Edit / Shape / File grouping survives the narrower layout.
Building the rail is also wrapped in a fallback now. It is presentation code and
must never be able to take the tools down with it: if it throws, our classes are
removed and ol-ext's default bar renders instead of nothing at all. The
matchMedia listener that would have been the most likely thrower (Safari only
gained MediaQueryList.addEventListener in iOS 14) falls back to addListener.
Verified: wraps to two rows in a 390px container, single row when wider, no
overflow or backdrop-filter in either, and still hides when Draw mode is off.
Service Worker v17 -> v18.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two separate faults, both reported from an iPhone running v16.
Portrait showed no toolbar at all. On iOS Safari an element that both blurs its
backdrop and scrolls frequently fails to paint, and v16's strip did both:
backdrop-filter inherited from the rail, plus overflow-x for the horizontal
scroll. The rail has no overflow, which is why the same build rendered fine in
landscape. The strip now paints an opaque background and switches the blur off.
`-webkit-overflow-scrolling: touch` goes with it — obsolete since iOS 13 and
implicated in the same class of bug.
Landscape showed the desktop rail. The breakpoint was width-only, and a phone
in landscape is 844px wide, so it did not qualify as a phone — but it leaves
only ~270px of map height, and the rail is 396px tall. The layout now switches
on either axis: `(max-width: 576px), (max-height: 560px)`. 560px is the point
below which the rail plus the navbar and dock no longer fit. The matchMedia
query that disables dragging on the docked strip matches the new condition.
Verified across all three states: narrow-and-tall and wide-and-short both give
the strip with no blur and no grip; wide-and-tall keeps the rail.
Service Worker v16 -> v17.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three problems reported from a device after v15, two of them the same mistake.
Phone layout filled the screen. The strip's rule and ol-ext's
`.ol-control.ol-bar.ol-left` both weigh (0,3,0), so stylesheet order decided
which won — and the built app loads them in the opposite order from the test
harness, which is why this passed review and failed on the phone. With ol-ext
winning, `top: 50%` survived next to `bottom: 12px` and the bar stretched
between them. The rail's selectors now carry enough classes to win outright
rather than by luck; both orders are verified.
Stray dark shape beside the option bar was ol-ext's pointer triangle. Same
cause: `.ol-editbar-rail .ol-option-bar::before` (0,2,1) never beat
`.ol-control.ol-bar.ol-left .ol-option-bar:before` (0,4,1).
The rail covered the settings toggle. `.offcanvas-toggle-left` sits at
left:10px, vertically centred — precisely where ol-ext docks a left bar, so the
rail landed on top of it. The rail now starts at left:66px, clear of the 44px
toggle, and can be dragged anywhere by a grip at its top. The position is
remembered in localStorage, clamped so the rail cannot be pushed off-map, and
reset by double-clicking the grip. Dragging is limited to the grip so a
mis-aimed tap moves nothing, and is off below the phone breakpoint where the
bar is docked above the dock.
Service Worker v15 -> v16.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Field officers reported three problems with the drawing palette: it had no
background and disappeared over light imagery, it sat centred at the top across
the area being drawn, and its icons did not match one another. All three had
distinct causes.
Layout. The bar is now a two-column rail docked to the left edge on a solid
panel, with its controls re-ordered into labelled Draw / Edit / Shape / File
bands by MapView._buildToolbarRail(). Two columns because a single file of
thirteen buttons plus labels runs past the bottom of a laptop map view; the
rail measures 75 x 363 px. Adding ol-ext's own `ol-left` class also makes
option bars (Select's Delete/Info, Split's sub-tools) fly out to the right
rather than dropping downwards.
Icons. ol-ext ships no icon font or sprite sheet — it draws each built-in tool
as CSS ::before/::after shapes from borders and box-shadows, which is why our
own Bootstrap-Icon tools looked foreign next to them. The generated shapes are
suppressed and every button gets an icon from a single TOOLBAR_ICONS map. The
glyph is injected as a <span>, not an <i>, because ol-ext styles
`.ol-transform > button i::before` and would otherwise out-specify Bootstrap's
`.bi::before` and blank the icon.
Phones. The rail becomes a horizontally scrolling strip above the dock, which
retires the two-row flex-break workaround and its `.ol-editbar-break` element.
Touch targets stay at 36 px, and option bars open upward.
Two things worth recording for anyone changing this later. The rail must not
set `overflow` on either axis: the other axis then computes to `auto` too and
clips the option bars that fly out past its right edge. And `display` in the
phone media query must stay free of `!important`, since ol-ext shows and hides
the whole bar with an inline style.
Also picks up ol-offset, an EditBar default that was never explicitly enabled
and had been sitting in the bar unlabelled; it is grouped under Shape.
Verified in a headless browser at desktop and phone widths, in light and dark
mode: icons, grouping, active-tool tint, option-bar placement, and that the bar
still hides when Draw mode is off.
Service Worker v14 -> v15.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reference notes for transforming Accra / Ghana National Grid geometry to WGS 84
in PostGIS: ST_Transform, ST_SetSRID for columns reporting SRID 0, updating a
column in place, and altering a column whose type modifier locks it to 2136.
Relevant wherever data arrives on the national grid while the API and the
application work in 4326 throughout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two documents, both for work the database team will carry out. No application
code changes.
OSM re-import runbook
---------------------
The roads endpoint returns geom, districtid, osm_id, surface, oneway and name
for district 1 — no `highway`. The road class was discarded at import, which is
what osm2pgsql's default.style does to any tag not on its list, so roads cannot
be styled or filtered by class. A discarded tag cannot be recovered by querying;
the only fix is to import again, and if that is happening anyway it is worth
doing on terms that allow the data to be kept current afterwards.
The runbook covers a flex import with --slim and --extra-attributes, producing
four tables — roads, lines, points, polygons — plus daily replication. Two
decisions are specific to LUPMIS:
* The import goes into its own `osm` schema. osm2pgsql --create drops and
recreates every table it owns, and `spatial` holds lu_parcels; the API is
given views in `spatial` instead, so the existing get_osm_roads.php keeps
working and simply gains `highway`.
* districtid is stamped by a spatial join after each import, since osm2pgsql
has no concept of a district.
Executed against osm2pgsql 2.2.0 and a scratch PostGIS database with a hand-made
extract covering every shape the config handles. All four tables imported
cleanly; updatable and attributes both true; amenities mapped as node, closed
way and multipolygon relation all reached the points table, as did a
healthcare-tagged node with no amenity — three cases the current layout cannot
serve. An --append then applied a diff and left districtid NULL on the changed
row while untouched rows kept theirs, which is the behaviour the incremental
re-stamp in section 5.2 depends on.
Not verified: anything needing the LUPMIS database itself — the current import
state, the district boundary table's real name, row counts. Section 1 is the
set of checks to run first.
External layer tables
---------------------
DDL for storing layers added through the Add External Layer dialog:
spatial.hlp_layer_types available layer types (wms, wfs, xyz, cog)
spatial.es_external_layers one row per layer a user has added
Layer types live in a table rather than a CHECK constraint so a new kind can be
introduced by inserting a row. The rule that WMS and WFS require a layer name
moved there too as requires_layer_name — left in the schema it would have meant
a migration for every new type anyway. Validity is enforced by a foreign key and
the conditional rule by a trigger, since a CHECK cannot read another table; the
trigger raises check_violation so existing API error handling still applies.
Beyond the dialog's four fields the table carries districtid (NULL = every
district), userid and is_shared, plus the columns needed to represent the
external layers the application already hard-codes — style, opacity, z_index,
attribution, legend_url, online_only. Without those, moving the existing
DEAfrica slope layer into the database would lose information the map relies on;
it is included as a seed row to prove the schema can hold it.
Executed against PostgreSQL 16. Nine cases pass, including: a new type added as
data with no DDL and usable immediately, that type enforcing its own
requires_layer_name, a retired type blocked for new layers while existing ones
stay editable, and deletion of a type still in use being refused.
The application still hard-codes the four types and will until
get_layer_types.php exists, so the dialog and hlp_layer_types must be changed
together until then — noted in the file, as the two fail in opposite directions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Preventive, not incident-driven. No LUPMIS host is known to have been
compromised; these rules exist so the most common automated attack against a
PHP deployment cannot succeed.
Scanners probe every public host for /.env (database passwords, API keys) and
/.git/. The second is the more damaging: given /.git/index together with
/.git/objects an attacker can reconstruct the whole repository, including its
history — so any credential ever committed is exposed even after rotation.
That is directly relevant here, because the minio-uploads integration has had
access keys written into its PHP files and those files are tracked in Gitea.
- public/.htaccess: deny hidden paths and files that should never be served.
- docs/apache-hardening.conf: the same rules for vhost/server config, which is
where they belong — .htaccess depends on AllowOverride. Also covers logging
the real client address through the openresty proxy (mod_remoteip), and
blocking at the proxy so requests never reach the application.
Two details that are easy to get wrong, and are handled:
- <FilesMatch "^\."> does NOT stop /.git/config. FilesMatch tests the basename
only, and there the basename is "config". Blocking a hidden directory needs a
rule that sees the whole path: mod_rewrite in .htaccess, <DirectoryMatch> in
server config.
- The SPA fallback only rewrites paths that do not exist (!-f), so a real .env
on disk skips it and is served as a plain file. The deny rules therefore run
before the fallback, not after.
.well-known is exempt, or ACME certificate renewal would silently break.
Verified against a live Apache instance with a planted .env and .git tree:
/.env and /./.env return 403; /.git/config, /.git/HEAD, /.git/objects/... and
/backup.sql return 404; /.well-known/acme-challenge/..., /assets/*.js and
/manifest.json still return 200. Nothing in the document root is caught — the
only dotfile there is .htaccess, which Apache never serves.
These rules are damage limitation. The fix is to keep .git and .env out of a
document root: deploy build output rather than a working copy, and hold secrets
in environment variables outside the served tree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contours hillshade layer
- Wired to the new endpoint POST /api/contours/get_by_district.php. It sits in
its own /contours module rather than /spatial_planning, so remotedb gained a
resolveEndpoint() helper: a bare filename still resolves inside
/spatial_planning, a path resolves against the API root. The endpoint also
names the district parameter `districtid` (numeric, no underscore) unlike
every other endpoint, which is an easy trap — documented at the call site.
- Each contour is labelled with its `elev` value. Labels are decluttered
(addGeoJSONLayer gained a declutter passthrough, since OpenLayers only allows
it at construction time) and gated by resolution, so the map shows an
occasional elevation value rather than one per segment.
- Verified against the live endpoint: district 1 returns 2,676 features, 11
elevation values at 10 m intervals, median feature ~50 m. The response uses
the standard {success, data:[…]} envelope; the reader also tolerates a bare
array.
Analyse: fail loudly instead of silently
- Overlays are polygon-only, but line inputs were being filtered out silently,
producing an empty result and the misleading "the layers may not overlap".
They now raise a message naming the geometry actually found and pointing at
Zonal statistics. Union checks each layer separately, so a line layer paired
with a polygon layer can no longer be dropped unnoticed.
- Zonal statistics returns warnings alongside the result — shown in the panel
and written into the Excel workbook — for figures that are valid but easy to
misread: total area over non-polygon inputs (always 0), and mean over line
inputs, which is not an area-weighted average of the surface. The mean
warning is restricted to lines; averaging over points is a normal statistic.
Measurements are read-only
- The Measurements layer carries selectable:false, honoured by both the Select
interaction and box-select. Previously a measured circle showed vertex
handles while the Digitise tool was active, and dragging one reshaped the
measurement: the intersection test followed the new shape while the reported
radius and area did not. Double-click analysis is unaffected — it hit-tests
the map directly rather than going through Select.
- Circle Analysis now reads the radius from the geometry instead of the
`_radius` snapshot taken at draw time, so the reported figure cannot drift
from the area analysed (that figure also feeds the PDF export). The property
is kept but marked as a snapshot, not a source of truth.
Documents
- New Buffer (Circle) tool user guide: the tool is named Circle rather than
Buffer, it takes two clicks rather than a drag, and the analysis only appears
on a double-click — the three things users were getting stuck on.
- New technician training plan: 12 topics over 60 days, derived from the
technologies actually used in this code base, including Docker.
- Analyse guide: elevation per zone from the contours layer, with the
min/max-versus-mean caveat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Excel export (from TWG feedback)
- src/analysis/xlsx.js: a dependency-free XLSX writer — an .xlsx is a ZIP of
XML, so this packs the required parts with a small stored-ZIP writer. Avoids
SheetJS (stale npm package with advisories) and ExcelJS (heavy for an
offline-first field app), and does not rely on the JSZip that only reaches us
transitively via shp-write. Lazy-loaded as a ~5.6 kB chunk.
- After a zonal run the Analysis panel offers "Export table (Excel)", writing a
two-sheet workbook: Results (figures as real numbers) and Parameters (zone
and input layers with feature counts, the "Apply to" scope, membership rule,
statistics, numeric field and export time) — so a table can be verified or
reproduced later rather than being an unattributed set of numbers.
The button is hidden for overlay runs and cleared when the mode changes.
- Verified against two independent readers: openpyxl loads it with zero
warnings and correct numeric types, and LibreOffice Calc opens it as a
spreadsheet. Also exercised end-to-end through the real zonal pipeline.
COG raster entry point
- Add External Layer gains a COG type alongside WMS/WFS/XYZ, with a URL
pre-flight check that distinguishes a web page, a 404, a CORS block and a
server without byte-range support — geotiff.js otherwise reports these only
as an opaque "AggregateError: Request failed".
Digital Earth Africa ETL
- etl/deafrica_dem_to_minio.py exports a DE Africa DEM for a district as a COG
and uploads it to the LUSPA MinIO bucket raster-objects, then verifies the
object is anonymously readable and range-capable. Credentials come from the
environment; the existing PHP integration hardcodes them and an earlier key
pair reached Gitea. NOTE: not yet run against the Sandbox — start with
--list-products to confirm the DEM product name.
Documents
- Concept note: Digital Earth Africa added as a raster source (§6.3), separating
the live WMS route from the batch Sandbox export; corrected the in-house
contour table's provenance to OpenTopography (gdal_contour over an SRTM 30 m /
Copernicus 30 m DEM), and added the ToR §2.4.2 / FS §2 alignment chapter.
- TWG presentation, architecture and two integration workflow charts (SVG
sources kept in the repo so they stay editable), and a user guide for the
Analyse tools.
Service worker v13 -> v14. .gitignore: exclude Python bytecode from etl/.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage 1 of the GIS Analytical Tools concept — client-side spatial analysis:
- src/analysis/overlay.js: vector overlays (intersect / clip / difference /
union-dissolve) on Turf.js, reprojecting to WGS84 and merging attributes
(intersect keeps both layers', clip keeps only A's).
- src/analysis/zonal.js: vector-in-vector zonal statistics — count / sum /
mean / min / max / total area per zone, with centroid-in-zone (default) or
any-overlap membership.
- Bounding-box pre-filtering in both: only genuinely overlapping pairs reach
the expensive geometry test. 43 zones x 25,004 parcels now completes in
~106 ms; previously it was refused as too large.
- src/analysis-modal.js + markup: Analysis panel with "Apply to" scoping —
whole layer, current map view, selected features, or the catch of a drawn
Circle/Area. Reached from a new "Analyse" dock button.
- MapView.addCOGLayer() for Cloud-Optimized GeoTIFF display (WebGLTile +
GeoTIFF source, imported lazily); listVectorLayers(); getSelectedFeatures().
- Circle/Area analysis popup: one "Export" button (PDF folded into the export
modal as a fourth format, field-rename table hidden for it) plus an
"Analyse" button that opens the panel pre-scoped to the intersecting
features.
- vite.config.js: code-split turf, geotiff and pako so the eager bundle is
unchanged (~283 kB). Giving pako its own chunk also fixes a circular chunk
between jspdf and geotiff, which share it via fast-png.
Drawing-tool fixes carried in the same working tree:
- Delete/Backspace key deletes the selection via the EditBar's Delete
interaction (same undoable block as the button).
- Multi-select: shift-click toggles, Ctrl/Cmd-drag box-selects; helper layers
(vertex overlay, GPS) excluded.
- Undo: split/merge/divide wrapped in undo blocks so one press reverses the
whole operation; vertex-overlay churn no longer pollutes the undo stack;
sources are re-scanned and the stack cleared when edit mode is entered, so
deletes on sub-grouped layers are undoable. NOTE: the undo behaviour is not
yet confirmed on-device.
- ol-ext TouchCursor gated to touch-only devices, so hybrid touchscreen
laptops keep the normal cursor.
Service worker v12 -> v13.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The landing page is the dispatch hub for the other LUSPA modules. Add a plain
navigation link to https://lupmis4luspa.org/ that keeps the SSO session intact
(no /?logout=1, no cache wipe), shown only when authenticated. Logout remains
the full SSO sign-out via /user-logout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The /?logout=1 endpoint destroyed only the PWA's own PHP session and then
redirected to the bare landing page — which has no session and immediately
blocks access, so the user was never actually logged out of SSO.
- public/index.php: after session_destroy(), redirect to
https://lupmis4luspa.org/user-logout (the portal's full SSO logout) instead
of the landing page. Crucially, no longer clear sso_auth_token here —
/user-logout needs that cookie to identify which SSO session to terminate
(and it clears the cookie itself). The production access-guard bounce to the
landing page is unchanged.
- main.js: drop the now-redundant best-effort client call to /sso/logout; the
server redirect chain (/?logout=1 → /user-logout) owns the SSO logout. Offline
guard and district-cache wipe unchanged.
- sw.js: update the v12 changelog note (still v12; not yet deployed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session / district correctness
- public/index.php: add a /?logout=1 endpoint that destroys the PWA's own PHP
session (session_destroy + expire PHPSESSID + clear sso_auth_token, then
redirect to the SSO portal). Logout previously cleared only the SSO cookie,
leaving the PHPSESSID session — and its frozen district_id — intact, which is
why a reassigned user kept loading the old district across logout/login.
- SSO token is validated once per session, at login (unchanged first-login
logic). A district transfer is now picked up on the next logout→login, which
is correct precisely because logout finally tears the session down. No
periodic SSO polling.
- main.js: the menu Logout button routes through /?logout=1 and wipes
district-scoped local caches first. Logout is blocked while offline — a
session can only be created online, so an offline logout would strand the
user with no way back in (and would not actually reach the server).
- main.js: enforceDistrictConsistency() clears district-scoped caches when the
session district changes between loads; the district boundary is cached under
a per-district key (district_boundary_<id>) so one district's geometry can
never be served for another.
GPS coordinate format
- New "GPS Coordinate Format" setting (Lat/Lon · UTM · Both) in the Settings
panel; the navbar read-out renders the chosen format and repaints the current
fix immediately on change. Self-contained WGS84→UTM converter in
geo-utils.js, verified against an independent Redfearn-series implementation.
ol-ext touch cursor
- MapView gates the TouchCursor to genuine touch-only devices via matchMedia
(any-pointer: fine / any-hover: hover); hybrid touchscreen laptops keep the
normal cursor. Reactive to pointer-capability changes.
- Service worker v11 → v12 (new shell). docs/SSO_Session_Refresh_Proposal.md
documents the implemented approach.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Default interaction mode changed from 'addLocation' to 'none' so that
opening the app does not silently arm the Add-Location cursor; users
must explicitly click the Add button to enter that mode.
- Add button now toggles: a second click returns to 'none' mode, matching
the behaviour of the Measure and Draw buttons.
- All other tool buttons (Circle, Line, Area, Draw) now return to 'none'
on toggle-off instead of re-activating Add-Location.
- Removed the hard-coded 'active' CSS class from the Add button in
index.html, consistent with the new default state.
- Added INSPIRE_HILUCS_Workshop.pptx (13 slides) as a workshop reference
deck covering INSPIRE Land Use Data Specifications, the HILUCS hierarchy
(3 levels, 13 top-level classes), example HILUCS↔LUPMIS2 zone-code
mappings, and a structured PROs/CONs analysis for LUSPA adoption,
including a pragmatic localisation recommendation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Evicts the stale shell + module caches at the next deploy so existing
clients pick up the permit-iframe auth-required card, the new
X-Frame-Options strip, and the import-UX refinements (parse spinner,
client_uuid tagging, geometry/delete persistence, sample values +
Unicode-bold field names in the mapping dropdown).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Permit-iframe hardening:
- public/embed.php — replace the 302 redirect on unauthenticated visits
with an in-iframe HTML "Sign in to view the map" card (HTTP 401)
whose primary button uses target="_top" to break the iframe and send
the parent window to the SSO portal. The 302 was broken UX inside an
iframe because the LUSPA portal refuses to be framed.
- public/embed.php + public/.htaccess — strip X-Frame-Options at the
embed endpoint (defence in depth). Apache's <Files "embed.php">
Header always unset X-Frame-Options + PHP's header_remove() both
ensure the only iframe-policy header on the response is our CSP
frame-ancestors (which already allows the permits subdomain). Fixes
Safari's "Refused to display ... because it set 'X-Frame-Options'
to 'SAMEORIGIN'" when the container's reverse proxy injects it.
Import UX refinements:
- Spinner overlay (index.html #import-spinner-overlay + main.js
showImportSpinner/hideImportSpinner) shown during the file-drop →
mapping-modal gap. Wired at the top of each handle*Import and at
every error / early-return path; hidden by stageImport() just before
openImportMappingModal() so it spans both the JS parse and the
SQLocal staging insert.
- Per-feature client_uuid tagging — each imported OL feature now
carries _externalImportId + _clientUuid set in stageImport(). These
tags are the link that lets later edits find the matching staging
row, and they are passed through to addExternalImportFeatures.
- Geometry-edit persistence — new public callback registry
MapView.onFeatureModified(cb) fired from a modifyend listener on
_modifyInteraction. main.js handler writes the new WKT (EPSG:4326)
back to external_import_features.geometry_wkt via new helper
updateExternalImportFeatureGeometry(clientUuid, wkt). Non-imported
features carry no tags, so the handler is a no-op for them.
- Delete persistence — removefeature listener on each imported layer's
source. New helper deleteExternalImportFeature(clientUuid) runs an
atomic DELETE + decrement of external_imports.feature_count and
broadcasts the changes so the LayerSwitcher badge can recount.
- Field-mapping dropdown — sample values + bold field names.
New helpers sampleSourceValues(fc) in import-detect.js (picks first
non-empty value per attribute, JSON-stringifies objects, collapses
whitespace, truncates to 35 chars) and toBoldUnicode(s) in
import-modal.js (ASCII letters/digits → Mathematical Alphanumeric
Symbols block). Options now read as "𝐮𝐩𝐧 — [12345-6789]";
HTML/CSS bold doesn't render inside <option> elements, so Unicode
bold codepoints are the cross-browser way.
Workshop deliverables:
- LUPMIS2_Improvements_Mar_to_Jun_2026.docx — handout mirroring the
slide deck one-to-one (160 paragraphs, branded styling).
- LUPMIS2_Workshop_Mar_to_Jun_2026.pptx — 16-slide pptxgenjs deck
(16:9 widescreen, brand palette, hero + content + closing masters,
embedded staged-upload diagram on slide 9).
- LUPMIS2_Staged_Upload_Flow.svg + .png — three swim-lane diagram of
the staged-upload pipeline with a dedicated "Client QA Gate"
callout. Hand-crafted SVG + 2400 px PNG.
save_gps_trail.php diagnosis (no code change, on the database team):
the reported "CORS" error is a missing endpoint — Apache returns 404
with no CORS headers and the browser surfaces it as access-control.
Once the endpoint is deployed the API server's global CORS handling
attaches the right headers and the GPS-trail sync will work without
client changes.
dist/ rebuilt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UPN-grid layer:
- src/database.js — new upn_grid SQLocal table (id, districtid, upn_prefix,
geometry_wkt) + saveUpnGrid / getLocalUpnGrid; cache-once-per-district.
- src/remotedb.js — getUpnGrid → get_upn_grid_per_district.php.
- main.js loadUpnGrid + upnGridToGeoJSON in the Administration group, with
a zoom-aware style: white casing under a bolder violet dashed stroke
(visible against parcels) and upn_prefix labels rendered only when
resolution ≤ 7 m/px (≈ scale ≤ 1:25,000).
- main.js click handler: single click on a UPN-grid cell opens an info
popup showing the upn_prefix.
External-dataset import → staging → upload (client-side complete):
- src/database.js — external_imports + external_import_features tables,
plus createExternalImport / addExternalImportFeatures /
updateExternalImport / getExternalImport / getExternalImportFeatures /
listExternalImports / remapImportedFeatureProperties /
deleteExternalImport. Status enum: imported/mapped/other/uploading/
submitted/migrated/failed (aligned with the database team's staged-
upload model — lu_parcels_upload_tmp + supervisor review).
- src/import-detect.js — pure helpers: detectTargetType(),
autoMapFields(), applyFieldMapping(), listSourceFields() + TARGET_TYPES
/ TARGET_FIELDS registries.
- src/import-modal.js — Bootstrap mapping modal: target dropdown,
field-rename table, three actions (Cancel / Save / Save + Upload now).
- main.js — stageImport hooked into addImportedGeoJSON (the single
convergence point for shp/GeoJSON/KML drops); handleImportModalResult
applies the mapping in one transaction; runUpload builds the real
payload (district_id + api_token from remotePost, user_id_upload from
SSO session, per-feature client_uuid/geom/props) and currently logs +
toasts — the upload_<target>.php endpoints are not yet live.
- index.html — #importMappingModal markup.
- MapView._decorateLayerListItem — import-state chip (Upload N /
spinner / ✓ submitted / ✓ live / N errors) dispatching
lupmis:import-chip-click; src/styles/layerswitcher.css — chip variants.
GIS export from Area / Circle Analysis popups:
- MapView._showAnalysisPopup now accepts an exportContext (clipGeometry +
parcelFeatures + zoneFeatures + otherByLayer) and renders an "Export
GIS" button next to "Export PDF". Click dispatches lupmis:export-gis.
- index.html — #exportGisModal markup.
- src/export-gis-modal.js — Bootstrap modal: format toggle (GeoJSON
default / Shapefile / KML), filename, field-rename table with SHP
10-char DBF warning.
- src/gis-export.js — writers: GeoJSON via Blob, KML via OL KMLFormat,
Shapefile via shp-write (with DBF-safe name sanitiser).
- Adds shp-write@0.3.2 dependency.
MapView style options:
- addGeoJSONLayer now accepts strokeDash for line-dash patterns (used by
the UPN-grid layer and available for any future contextual overlay).
Service Worker v9 → v10 to evict the stale shell/module caches on the
next deploy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Major:
- GPS trail recording: reusable, dependency-free engine in src/geotracker/
(GeoTracker + geo-utils) with pluggable storage/sync adapters; LUPMIS
wiring in src/geotracker-lupmis.js. Expandable My Location control
(Locate Me + Record Trail), live navbar GPS readout, on-map trail/position
rendering, gps_trails/gps_trail_points SQLocal tables, and store-and-forward
sync via pushGpsTrail() -> save_gps_trail.php (server side documented, not
yet built).
- SSO authentication: public/index.php entry point validates the LUSPA SSO
cookie and injects window.LUPMIS_SESSION; remotedb district_id is now a
session-resolved getter. Adds public/.htaccess (DirectoryIndex).
- Account menu offcanvas (navbar burger) with sign-in/out states.
UI / fixes:
- LayerSwitcher modernisation; base-map "None" option in picker + settings.
- Mobile drawing toolbar wraps to two rows below 576px and shows only in
Draw mode; second row right-aligned and clears the Select option bar.
- Safari bottom-dock clipping fixed (app-container 100dvh -> 100svh).
- Rename public/icons -> app-icons to dodge Apache's default /icons/ alias.
- Service Worker bumped to v8 (network-first HTML, per-provider tile clear).
Docs: reusable-mapping and OSM-3D-buildings concept notes; ignore Office
lock files (~$*). Rebuilt dist/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comprehensive .gitignore covering:
* node_modules/ (re-installable from package-lock.json) — also untracks
the 5 679 files that were carried over from the initial commit
* Vite dependency cache (.vite/) — pure build churn
* IDE state: BBEdit (*.bbprojectd/), VS Code, JetBrains, Zed, Sublime,
Vim swap files
* OS metadata: macOS .DS_Store / ._*, Windows Thumbs.db, etc.
* Interim Word-document backups (*-v[0-9].docx pattern), env files,
test coverage, common cache directories
dist/ deliberately NOT ignored — the repo currently serves the built
output directly. If you switch to a CI deploy later, uncomment the
dist/ lines in .gitignore.
After this commit, `git status` will be empty until real source changes
are made (no more node_modules / .vite cache noise).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>