28 Commits

Author SHA1 Message Date
4edb0a2f37 Add user manual Part 1; cross-reference the two tool guides
Part 1 covers the parts of the application that have been stable for months:
signing in and district scoping, the map screen, navigation, base maps, layers,
locations, measuring, an orientation to Analyse, exporting, working offline,
GPS, settings, and a troubleshooting table. Nine pages, built from the Analyse
tutorial so it matches the existing guides rather than looking like a new
document — same cover, header, footer, headings, call-outs and tables.

Held back deliberately: the drawing and digitising tools, which changed across
v15-v23 and want a settling period in the field, and anything depending on
server endpoints that are not deployed. Those are Parts 2 and 3. The manual is
stamped with the application version it describes (23), because an older
installation behaves differently around the toolbar.

Section 7.2 documents the Markers layer behaviour, including that switching it
off yourself will not stick, since that is the part most likely to look like
data loss.

On the two guides: I had said they were stale because they described the removed
Add Location button. That was wrong — neither mentions it, and I asserted it
without checking. What they did need was smaller. Both gain a Related documents
section so the three files read as a set, and the Analyse guide's manual-selection
instruction now says where the Select tool is, which it could not have when it
was written before the toolbar was banded into groups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 10:28:07 +02:00
56dd8cbda4 Show the Markers layer whenever the district has locations
Switching the layer on only at the moment a location was saved was too narrow.
loadLocations() runs at startup and after every change, and it left the layer
hidden — so markers were visible right after saving and gone again on the next
reload, which is what made it look as though closing Digitise had removed them.

The call moves into loadLocations(), guarded on there being at least one
location. That covers startup, saving, and any later refresh from one place,
and the two calls in the add paths that duplicated it are gone. An empty
district still opens with the layer off, which is what it was created hidden
for.

One trade-off, since it was asked about: an officer who deliberately unchecks
Markers in the layer switcher will find it back on after the next reload or
saved location. Respecting that choice would mean persisting it, which is worth
doing only if anyone actually wants the layer off while holding locations.

Verified both cases: with locations the layer goes visible on the startup load
and stays visible through entering and leaving Digitise and through a reload;
with none it stays hidden throughout.

Service Worker v22 -> v23.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 10:05:38 +02:00
5967b99a73 Keep the placed point on screen until it becomes the marker
Placing a point with Draw > Point removed it immediately and opened the Add
Location form, so the officer filled in a form with nothing at the spot they had
just tapped, and a marker then appeared from nowhere. Nothing visibly turned
into anything, which is what made it read as broken.

The sketch now stays until the form closes. On save the marker has already been
added by then, so the point is replaced by it; on cancel, or on leaving the
drawing tools with the form still open, it is simply discarded. Placing a second
point while a form is open replaces the pending sketch rather than leaving the
first one behind.

Marker loading is also taken out of the undo history. Entering edit mode calls
UndoRedo._watchSources(), which picks up every vector source including the
markers — so reloading the Locations list registered as two undoable edits, and
Undo after saving a location stripped the markers off the map while leaving the
row in the database. That is the same "my point disappeared" symptom by another
route. clearMarkers() uses _silentClearSource for the reason already documented
on the vertex overlay: ol-ext patches VectorSource.clear to fire block events
that the record flag does not gate.

Verified all three endings — save, cancel, and abandoning by leaving Digitise —
plus placing a second point mid-form, and that the undo stack stays empty
through the whole sequence with recording switched back on afterwards.

Service Worker v21 -> v22.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 09:45:58 +02:00
f92a3d43c1 Show the Markers layer when a location is created or focused
Points placed with Draw > Point were written to the database but never appeared
on the map. The cause is not in the new workflow: the Markers layer is built
with visible: false so a fresh map is uncluttered, and nothing in the
application ever switched it on — only the LayerSwitcher could. So the record
was saved, the map panned to it, and nothing was drawn.

This predates the workflow change; making Draw > Point the single way to add a
point is what made it obvious, because placing a point is now the common
gesture rather than an occasional one.

MapView.showMarkers() switches the layer on, and the three places that act on a
specific location call it: adding one from the map form, adding one from the
sidebar form, and picking one from the Locations list. The default stays hidden,
so a map with no locations in play still opens clean.

Traced the marker count and layer visibility through the whole sequence — add,
enter Digitise, draw, save, pick Select, leave Digitise. The count was always
right and the layer was false throughout, which is what pinned the cause; it is
now true from the save onward.

Service Worker v20 -> v21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 17:57:03 +02:00
b28a34bc38 One way to add a point: the Draw tool, not a separate dock button
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>
2026-08-20 17:43:02 +02:00
1c0d4319b7 Drawing toolbar: lift the phone strip clear of the map controls
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>
2026-08-20 17:04:08 +02:00
7787f77f80 Drawing toolbar: restore the two-row wrap on phones
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>
2026-08-20 16:47:59 +02:00
d165b57853 Drawing toolbar: fix iPhone portrait and landscape
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>
2026-08-20 16:18:16 +02:00
19dc000efe Drawing rail: fix phone layout, stray pointer, and make it movable
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>
2026-08-20 15:42:29 +02:00
e9bb4d81c9 Drawing toolbar: docked left rail, Bootstrap Icons throughout
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>
2026-08-20 15:25:54 +02:00
f8e4a3a19d Add notes on reprojecting EPSG:2136 to 4326
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>
2026-08-20 10:48:24 +02:00
8f48abc858 Docs for the database team: OSM re-import, external layer tables
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>
2026-08-20 10:42:16 +02:00
664c242980 Apache hardening: block .git and .env disclosure
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>
2026-08-11 12:53:20 +02:00
7d26c740df Contours layer via new API; Analyse guards; measurements read-only
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>
2026-08-11 12:20:26 +02:00
da6f968725 Zonal-statistics Excel export, COG layer entry point, DE Africa ETL
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>
2026-07-23 16:28:45 +02:00
26c941b09a Analytical tools Stage 1 + drawing-tool fixes
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>
2026-07-21 14:09:06 +02:00
1d5c460f70 Menu: add "Back to Landing Page" (stay signed in) alongside Logout
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>
2026-06-25 14:57:31 +00:00
c335a8987e Logout: perform full SSO logout via /user-logout
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>
2026-06-25 12:29:45 +00:00
d8ddbbc910 Fix stale district + offline-safe logout; GPS UTM format; touch-cursor gating; SW v12
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>
2026-06-25 11:58:36 +00:00
3479b00d83 Add Location toggle-off fix + INSPIRE HILUCS workshop PPTX
- 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>
2026-06-23 20:27:50 +00:00
4d594f58e5 Service Worker v10 → v11
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>
2026-06-23 13:37:23 +00:00
203ca5bc4d Permit-iframe hardening, import UX refinements, workshop deliverables
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>
2026-06-23 13:16:37 +00:00
26d4f6235f UPN-grid layer, external imports/staged upload, GIS export, SW v10
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>
2026-06-19 11:02:41 +02:00
933bfcf4c0 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>
2026-06-01 16:20:15 +02:00
cfaceb3487 GPS trail recording, SSO auth, account menu, and mobile/UI refinements
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>
2026-05-28 16:08:37 +02:00
9b57ff9e22 Add .gitignore and untrack node_modules / IDE state
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>
2026-05-26 10:58:25 +02:00
ef12e4477b Offline tile cache, polygon Divide, topographic layer integrations
Major feature batch covering drawing-tool improvements, layer additions,
and offline-first capabilities. Largest changes in MapView.js (+1700),
main.js (+1500), public/sw.js (+367), and new modules under src/.

Drawing & editing toolkit
  * Polygon Divide tool — sub-button under Split, divides a polygon into
    N equal-area pieces via binary search; user picks the cutting edge
  * UPN pick phase after Split and Divide — non-picked pieces have their
    identifier fields cleared automatically
  * Improved Merge algorithm — vertex-to-edge proximity (5 m tol.) with
    hybrid lockstep extension; bold A/B labels on selected polygons
  * Persistent vertex highlights — all vertices of the selected polygon
    rendered as dots while edit mode is on, without subclassing ol-ext
  * Toast notifications for merge/split/divide outcomes
  * Shapefile import — addGeoJSONLayer now includes an image style so
    Point features render (previously invisible)

Background & overlay layers
  * DEAfrica Coastlines v0.4 (WMS) in Biophysical Environment
  * DEAfrica Slope (SRTM 30m, style_slope) — semi-transparent background
  * Contours hillshade — get_contours_hillshade.php → local SQLite cache
  * OSM_roads — get_osm_roads.php → local SQLite cache, casing-stroke
    style (black 3.5 px outer, #F0F1F0 1.5 px inner)
  * External Source dialog — green + button in LayerSwitcher lets users
    add WMS / WFS / XYZ layers at runtime
  * Generic addWMSLayer / addXYZLayer with style, opacity, zIndex,
    legendUrl, onlineOnly options
  * TileWMS replaces ImageWMS (fixes 'Width exceeds 512' WMS errors)
  * Legend panel — bottom-right, auto-shown for visible layers that
    register a legendUrl
  * Default base map setting in Settings, persisted in localStorage;
    setBaseMap() on MapView

Offline tile cache (Phase 1 + 2)
  * Service worker: per-host tile caches (osm / topo / satellite /
    carto-light / carto-dark), counter-based eviction to prevent
    iOS Safari memory-pressure reloads, GET_TILE_STATS /
    CLEAR_TILE_CACHES message API
  * pwa.js helpers: getActiveServiceWorker, onServiceWorkerControllerChange,
    getTileCacheStats, clearTileCaches, getStorageEstimate
  * Settings: Offline Map Tiles card with per-provider stats + clear
  * Phase 2 download dialog: form to pick base map, area (current view /
    district / Ghana), zoom range; live tile-count + size estimate;
    progress bar with cancel; OfflineTileDownloader class with
    concurrency + throttling

Local database management
  * osm_roads table + saveOSMRoads / getLocalOSMRoads helpers
  * CACHED_LAYER_TABLES allow-list with clearTable / clearAllCachedLayers
  * Local Database Tables card: per-row Clear button (cached layers
    only) + 'Refresh cached layers' header button with reload prompt

Build & infrastructure
  * Shpjs lazy-loaded via dynamic import (saves ~140 kB from initial JS)
  * chunkSizeWarningLimit raised to 900 kB (openlayers + sqlite3.wasm
    can't be split further)
  * Toast notification module (src/toast.js)
  * Units module (src/units.js) for metric / imperial conversions
  * PDF export module (src/pdf-export.js)

Documentation & SQL
  * Topographic_Background_Layers_for_LUPMIS2.docx — research report
  * OpenTopography_Workflow.svg/.png — ETL pipeline diagram
  * LUPMIS2_Development_Status_Report.docx — April update section
  * sql/create_landuse_parcels.sql — PostgreSQL schema for the LUSPA
    land-use parcel specification (Feb 2026, revised), with PostGIS
    geometry column and standard indices

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:55:30 +02:00
3009a11b33 intial pwa LUPMIS2 commit 2026-03-04 12:59:40 +01:00