Compare commits

...

26 Commits

Author SHA1 Message Date
ea3ad5726b Training Plan: set the document Author to Ekkehardt Roth, GOPA
The file had been re-saved by Word, so it showed as modified, but the content is
unchanged — all 200 text blocks are identical to the committed version. What it
did carry was an Author property of "Un-named", which is now set to the
consultant of record, matching the convention for every project deliverable.

Only docProps/core.xml was rewritten; the other nineteen zip entries were copied
byte for byte with their original compression, so the embedded figures and
styles are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 15:16:17 +02:00
1f8b0c96c0 Work breakdown for the open scope: 61 tasks, 163-268 developer days
A sequenced schedule of everything not yet addressed, drawn from the Functional
Specifications v4, the Open Issues and Estimates handout of 18 September 2026,
Development Status Report v9, and this month's review of the running system.

The estimates from September are carried over unchanged; a Source column marks
which figures are the project's own, which come from the status report, and
which are mine. Twelve items are new from the October review and were not in
either document: the Save action in the drawing toolbar has no listener
registered, so edits are never written back; overlay analysis holds the main
thread for about nine seconds at 8,000 features; spatial.lu_parcels carries
3.1 million rows with zero indexes; the parcel geometry column is declared bare
so catalogues report SRID 0 while the data is EPSG:4326; collector zones sit in
a text column as WKT; and the Applicant Portal's only dependency on this
module, the parcel-map embed endpoint, is not built.

The Seq column is a recommended start order rather than a serial plan, since
fourteen of the sixty-one belong to infrastructure, the database team, LUSPA or
the permitting team. It opens with the container's AllowOverride setting,
because that leaves an already-deployed fix inert, and raises the four LUSPA
inputs and the MoU process early only because they have the longest lead times.
Foundations follow — index and repair the parcel and zone data before building
on it — then the pilot-workshop feedback, then the remaining specification
scope.

Role-based access control and the server audit trail appear once rather than
twice, and a third sheet lists what has closed since September so nothing is
counted in both places.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 15:11:14 +02:00
4952c92e42 Reconnect the app to the API, and fix the session that was never injected
Four changes that together get a signed-in officer back to working data.

The SSO validation in index.php and embed.php called the public hostname from
inside the server. The Lands Commission's NAT rule blocks traffic that leaves
the server and tries to come back in, so that curl stalled for its full
30-second timeout and then failed validation, bouncing the user back to the
login. Both now call http://l4lmainbackend/sso/validate, overridable with
LUPMIS_SSO_BASE. Measured from inside the spdrawing container: 25 ms with no
redirect, against a 10-second timeout on the public name.

API_ROOT is now the relative path /api. The PWA host already proxies it to the
ApiEngine container — a deliberate route, not a catch-all: /api answers on pwa.
and api. but not on permits., minio. or minioapi. Same-origin removes the
mixed-content, CORS and CORP constraints that Cross-Origin-Embedder-Policy:
require-corp imposes on a cross-origin call, and no API hostname is compiled
into the bundle any more. The dev server proxies /api to the public host, which
is right there because a developer's machine is outside the server and only
callers inside it are blocked from the public name.

A direct request for /index.html bypassed index.php, which is what injects
window.LUPMIS_SESSION, so the app started with no session at all and said so in
the menu. The LUSPA landing page links to exactly that URL, and the SPA
fallback could not help because its RewriteCond !-f fails precisely when the
file exists. .htaccess now routes it through index.php; index.php reads
index.html from the filesystem, so there is no loop. This one is inert until
AllowOverride is relaxed on the PWA container, where it is currently None.

Service worker v29, and v28 before it, which carries the GIS Tools rename to
devices that already have the app installed. The tile cache stays pinned at
v24, so downloaded map areas survive both updates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 15:11:01 +02:00
e82f4cae60 Assessment: ECMWF open data is not usable for the parcel-level analysis
Two pages on whether the ecmwf-opendata package and the ECMWF real-time open
dataset can feed LUPMIS2's Analyse tools. They cannot, and the reason is
resolution rather than anything fixable: the open subset is a 0.25 degree grid,
and one cell at Ghana's latitude covers 762 km². Sagnarigu Municipal is 440 km²
and touches three of those cells, two of which hold 89 % of it — so zonal
statistics over its 82 collector zones would hand all 82 one of three numbers.
The figure is drawn from the real district boundary rather than sketched.

Three further findings: it is a forecast rather than a climatology, so it
cannot answer what a place is normally like; the archive is the last 12 runs,
about two to three days, so no history can ever be fetched; and the analysis
tools take vector layers only, so a raster cannot be fed in today at any
resolution.

What it can do is a district-level forecast overlay — rainfall and temperature
for the coming days — which is about two days of work and reuses the path that
already publishes koforidua_dem.tif to the object store as a cog layer. Page 2
outlines that pipeline with the effort broken down.

ECMWF is moving the open subset to 0.1 degrees, expected by the end of October
2026, which would cover the same district with 10 cells instead of 3. That does
not change either recommendation, but it does make now a good time to build the
pipeline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 17:34:16 +02:00
7a6bf9b022 Training: topic 7 (PostgreSQL and PostGIS) and a topic 0 for workstation setup
Topic 7 follows section 6.7 of the Training Plan: six exercises over about
eleven hours, with answers and seven figures. Reading a schema you did not
design; SQL that answers a planning question; geometry and the ST_ functions;
indexes and query plans; backup, restore and a safe migration; then the
hands-on — parcels per zone for Sagnarigu Municipal, answered two ways.

Unlike the earlier topics this one had a real database to work against, so
every query, plan and timing in the answers was measured rather than described.
Parcels per zone takes 14 779 ms as the data is stored, 140 ms once the zone
geometry is a geometry column with a GiST index, and 53 ms with the parcels
indexed too — the same answer all three times, and almost all of the gain from
the type rather than the indexes. A district count on the live table is a
sequential scan over 211 264 buffers in 2.6 seconds; indexed, it is 60 buffers
and 16 ms.

The hands-on ends somewhere more useful than a number. The attribute answer
places all 67 560 parcels in a zone; the spatial answer places 27 880, because
the collector-zone polygons cover 64 km² of a 440 km² district. Where both
speak they agree 99.4 % of the time, so the likeliest reading is incomplete
zone polygons rather than wrong attributes — which is a reading, not a result,
and the exercise says so and sends it to the trainer.

Topic 0 is new and is not a teaching day: the half day before topic 1, covering
what has to be installed and proving it afterwards. Windows step by step with
the installer choices that matter, macOS in a dozen Homebrew commands, cloning
LUPMIS2 and getting npm run dev to a map, restoring the training database, a
nine-command readiness check, and the five failures that account for nearly
every lost morning. Written for machines where the trainee has administrator
rights, with every tool installed in one pass.

Still open in topic 7: exercise 6 part D compares the SQL with the application's
own zonal statistics, and only the SQL side is measured — the LUPMIS API was
unreachable. To be redone when the server is back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 17:30:25 +02:00
7ce20bd498 Training exercises: topics 5 (ol-ext editing) and 6 (PWA and offline)
Topic 5 follows section 6.5 of the Training Plan: six exercises over about
eight hours, with answers and six figures. The EditBar and the interactions
behind it; draw, modify and transform; selecting one thing or several; undo,
redo and blocks; three faults and how to find them; then the hands-on task —
break the vertex overlay on a branch and reproduce the defect where the first
press of Undo does nothing. All three states of that defect were measured in a
browser and the answers quote the numbers: one entry on the undo stack when
the code is right, six with the recording guard removed, two with the guard
back but the overlay still cleared with clear().

Topic 6 follows section 6.6: six exercises over about eleven hours, with
answers and nine figures. Proving the service worker intercepts; the four
caching strategies and which URL gets which; making a release without leaving
users on stale files; SQLocal, SQLite and OPFS; switching the network off on
purpose; then the hands-on task from the plan — a field_notes table, a
precached reference file, and the whole feature demonstrated with no server
running at all. That solution was written, built and run end to end before it
was printed, and the console figure is that run.

Both documents send the trainee to reproduce a defect that has since been
fixed rather than describing one as live: topic 5 by taking a class off a CSS
selector, topic 6 by merging two guards in the service worker back into one.
Figures retaken from the fixed build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 11:05:31 +02:00
4809ef324a Three offline and toolbar fixes, and no service worker in development
The drawing rail's option bar covered the Transform button. ol-ext positions
the flyout with `left: 100%`, which is meant to clear the whole rail; the rule
that makes that work sets the rail's controls to `position: static`, and it
only tied with ol-ext's own `.ol-control.ol-bar .ol-control` on specificity, so
stylesheet order decided it and ol-ext's injected sheet came last. The flyout
was therefore measured against the Select button and landed on the second
column. The selector now carries enough classes to win outright. This is the
same failure as v16, in a second place.

The local database did not open when the device was offline. The fetch handler
skipped every request whose destination was 'worker', to stay clear of the Vite
dev server's module plumbing — but in a build SQLocal's worker is a hashed file
under /assets/, so it was never cached either. With the network down the
request failed, initSchema() never resolved, and everything waiting on the
database stopped without an error. The shell, the map and the tiles all worked,
so the application looked like it was surviving offline while nothing could be
saved or read. The skip now covers only the dev server's own paths; a built
worker is cached like any other asset. Verified both ways against a production
build with the preview server stopped: before, the schema hangs and the table
list is empty; after, the schema completes and all twelve tables are listed.

The service worker is no longer registered on the development server. It cannot
do its job there anyway — the modules it would need come from node_modules,
which it deliberately keeps clear of — while it does cache main.js and src/*.js
cache-first, so it can serve a stale module and fight hot reloading. In Safari
it also costs the local database outright: a worker script that reaches the
browser through a controlling service worker loses its cross-origin-isolation
headers, and SQLocal's worker is refused. Because switching registration off
does nothing for a worker already installed, initPWA now unregisters whatever
it finds and clears the app caches when registration is off. That clearing runs
on every load rather than only on the one that unregistered: an unregistered
worker goes on controlling its page until the next navigation and refills the
caches behind us. The tile caches are left alone — they hold what an officer
downloaded for field work.

CACHE_VERSION goes to v27 for the first two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 11:05:11 +02:00
4132a12b38 Topic 1 exercises: tidy two figure crops
Checking every figure in topics 1 to 3 the way topic 4 should have been
checked found no wrong pictures, and two untidy ones, both in topic 1: the
Settings figure ran into a half-visible second row of cards, and the Messages
figure carried a strip of map down its left edge. Both re-cropped from the
same screenshots and swapped in place, leaving the rest of the file untouched.

Topics 2 and 3 are correct as they stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 14:18:26 +02:00
78c26dab5a Topic 4 exercises: three figures were cropped wrong
A reviewer spotted the pre-flight error message cut off mid-sentence. The
toast wraps to two lines and the crop kept one. Checking the rest of the
document the same way found two more: the "Add External Layer dialog" figure
and the "pop-up" figure were both pieces of map with neither dialog nor pop-up
in them.

All three re-cropped from the same screenshots, and every figure in the
document checked side by side at the size it prints rather than trusting the
crop arithmetic. The caption on the error message now also says why it names
two possible causes: the browser will not reveal which one it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 14:15:14 +02:00
dd6f0783bb Training exercises: illustrate topic 3, and add topic 4 (OpenLayers)
Topic 3 gains ten figures, in the same way as topics 1 and 2: the build as it
runs, what a build folder holds, the development server and the build side by
side, the entry file's own imports, what a visit downloads against what waits,
the Export dialog, the anatomy of a LUPMIS2 dialog, what an .xlsx really is,
the branded PDF report, and the Location summary dialog empty and filled.

Topic 4 — OpenLayers: building the map — is new, following section 6.4 of the
Training Plan: six exercises over about nine hours, with answers and eight
figures. The map, the view and the projection; layers, sources and the
switcher; styling, including by zoom; external layers and how to read their
failures; clicks and pop-ups; then the hands-on task, a Health facilities
layer added end to end — fetched, styled by category, placed in its own group,
and answering a click with a pop-up.

Both solutions were built and run before being written up. The topic 3 code
was taken back out of the document and applied to a copy of the project, where
it parsed and ran unchanged; the topic 4 layer was written, run, styled and
clicked, and the screenshots in exercises 3 and 6 are of it.

Still left out of all four documents, until someone can capture them: the
developer-tools panels and the Gitea pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 10:53:55 +02:00
3ca6d72c98 Training exercises: illustrate the steps in topics 1 and 2
A peer review asked for pictures showing what a trainee should be seeing.
Fifteen figures added, each under the step it illustrates, with a caption.

Topic 1 (ten): the terminal after npm run dev; the application as it opens,
with the Distance button, the Settings arrow and the right-panel arrow
numbered; the dock with Distance off and on; the dock in dark mode; the
console session of exercise 3; the Settings panel with the Measurement
System switch; what fetch returns and the 200-with-HTML trap; the
click-to-screen chain; the distance label on the map; the Messages list.

Topic 2 (five): the three places a change can be; fast-forward versus a
merge commit; the conflict markers annotated; what .gitignore reaches and
what it does not; the sequence when a key is pushed.

The screenshots are captures of the application running from this source at
double resolution; the console figure is real output, which corrected a
mistake in the making — a module's exports list alphabetically, not in file
order, and the figure now says so.

Left out for now, to be added when someone can capture them: the developer
tools panels (Network, Elements, Sources at a breakpoint) and the Gitea
pages, neither of which can be photographed from here. The Analyse panel's
statistics list is also missing, since it needs district data from the
server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 08:45:32 +02:00
ce6c922554 Status report: the Shapefile and startup fixes; training exercises for topics 1-3
Status report, section 18:
- 18.9 records that Shapefile export never worked — shp-write cannot run in
  this build — and what replaced it, including the check still outstanding:
  opening an exported file in QGIS or ArcGIS.
- 18.10 records the 428 kB that every visit downloaded although the
  application was built to avoid it, the two causes, and what a visit
  fetches now.
- Section 18 intro, Service Worker range v15-v26, Documentation and Open
  items renumbered, and a new open item for the desktop-GIS check.

Self-paced exercises for the first three topics of the Technician Training
Plan, on the LUPMIS2 template, each with answers:
- Topic 1 (6.1, web development foundations): seven exercises on the real
  application — the browser's view of it, its own modules in the console,
  tracing the Distance button from click to label, and one safe edit.
- Topic 2 (6.2, version control and secrets): eight exercises, from a first
  commit to a Gitea pull request, ending with a committed secret and the
  MinIO case. Every command was run in a scratch repository.
- Topic 3 (6.3, tooling, dialogs and reporting): six exercises ending in the
  hands-on task, a new dialog with a spreadsheet export; the solution given
  in the answers was built and run. Exercise 2 now finds the startup-loading
  defect through the history that fixed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 14:09:12 +02:00
adbf9ba211 Merge the Shapefile export fix and the startup-loading fix
Two branches from parallel sessions, merged in that order because the first
removes shp-write and with it JSZip, which the second was working around.

Conflicts resolved by hand in vite.config.js: the JSZip chunk rule the second
branch added is dropped, since nothing reaches JSZip any more — shapefiles are
written by src/shapefile-write.js and shpjs 6 unzips with but-unzip. Vite's
preload-helper rule stays, which is what keeps the geotiff chunk out of the
startup preload. dist/ was rebuilt from scratch rather than merged, its hashed
file names being a conflict on every file.

Service Worker v26 now records both changes.

Verified on the merged tree: a production build preloads only the entry,
vite-helpers, bootstrap, openlayers and ol-ext — geotiff, shpjs and pako are
gone from startup, and Analyse still fetches its chunk and turf on first use.
Shapefile export, driven through the Export GIS modal, writes a zip holding the
point and polygon sets with their .prj and .cpg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:48:02 +02:00
8674ca4df2 Build: stop the lazy geotiff and shapefile chunks loading at startup
vite.config.js splits geotiff and shpjs out so they load only when a user opens
a Cloud-Optimized GeoTIFF or imports a shapefile, but the built index.html
preloaded both on every visit — about 428 kB (152 kB gzipped) nobody had asked
for.

Two separate causes, both visible in the entry chunk's first line:
- it imported Vite's preload helper, used by every dynamic import(), from the
  geotiff chunk. The helper is a virtual module with no node_modules path, so
  Rollup had parked it there; needing it at startup dragged the whole chunk in.
  It now has a chunk of its own, about 1 kB.
- it imported JSZip from the shpjs chunk. JSZip reached the bundle through
  shp-write, which gis-export.js imports for Shapefile export, so it is needed
  at startup whatever happens; filing it under shpjs pulled that chunk in too.
  It is now its own chunk, costing the 31 kB it actually is.

dist/ rebuilt: index.html now preloads vite-helpers and jszip instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:40:13 +02:00
8d1d874ad7 Shapefile export: write the format ourselves instead of shp-write
Choosing Shapefile in the Export GIS modal produced no file. shp-write@0.3.2
cannot run in this build, in three separate ways: its dbf dependency assigns an
undeclared variable, which was fine in the browserify bundle it was written for
but is a ReferenceError under the strict mode ES modules always use; its zip.js
branches on process.browser, which does not exist in a browser; and its
download.js delivers the archive by navigating to a data:application/zip URL,
which Chromium blocks in the top frame.

- src/shapefile-write.js writes .shp/.shx/.dbf/.prj/.cpg itself: geometries
  split by type into up to three layers, polygon rings re-wound clockwise as
  the format requires, DBF column types inferred per column, names sanitised
  to DBF's 10 characters and de-duplicated, UTF-8 declared via .cpg.
- src/zip-store.js holds the STORED-ZIP writer that was inside analysis/xlsx.js,
  so both writers share it. Pure-ASCII archives are byte-for-byte as before.
- shp-write, and with it dbf and JSZip 2.5.0, leave the dependency tree; the
  build no longer warns that node's "assert" was externalised.
- Service Worker v26 for the new bundle.

Verified in a browser: the Export GIS modal, driven through its own controls
with Web Mercator features, produced area_analysis.zip holding both layers with
WGS84 coordinates, file code 9994 and the right shape types. The archive reads
back through the application's own shpjs importer with geometries, numeric,
text and boolean attributes intact. The zonal-statistics .xlsx export, which now
shares the zip writer, still opens and reads correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:40:00 +02:00
d8b635b442 Status report: Carto key and offline-tile fix; open-issues handout
Status report, section 18:
- 18.7 Carto base maps: the access key, why the parameter name matters,
  where the key is configured, and why it is visible by design.
- 18.8 Offline map tiles kept across updates (SW v25): every update since
  v4 deleted downloaded tiles; tile caches are now versioned separately
  and older tiles are moved rather than deleted.
- Documentation and open items renumbered to 18.9 and 18.10; new open
  items for re-downloading offline areas and the CARTO key on deployment.

Section 4 (comparison with the functional specifications): the vector
import/export row now lists Shapefile, GeoJSON and KML export and is
Partial, since GeoPackage and DXF are not yet supported; typo fixed in the
document management row; counts now 11 complete, 13 partial, 3 not started.

LUPMIS2_Open_Issues_Estimates.docx: two-page meeting handout. Page 1 lists
open issues with development estimates; page 2 lists what is not yet built
from Functional Specifications v4, with scoping estimates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:33:08 +02:00
26c8438e04 Service Worker v25: releases no longer delete offline map tiles
Tile cache names included CACHE_VERSION, and activation deletes caches
whose names are not current, so every release threw away every tile
officers had downloaded for field work.

Tile caches now carry their own TILE_CACHE_VERSION, frozen at v24 so
devices on v24 keep their tiles untouched. Tiles in any older tile cache
(including the legacy tiles-v1 bucket) are moved into the current
per-provider caches instead of deleted: nothing current is overwritten,
limits are respected, and the old cache is removed only after a
successful copy. Keyless Carto and Google tiles are not carried over.
Old shell/modules/api caches are still deleted, and clients are claimed
before the tile move so the page is not held up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:00:52 +02:00
486480eda6 Carto base maps: send the CARTO API key
Since about 28 August 2026 CARTO stamps an "API KEY REQUIRED" watermark into
every basemaps.cartocdn.com tile requested without a key. The request still
returns HTTP 200, so nothing fails — Carto Light and Carto Dark simply started
drawing the watermark over the map.

Both layers now build their tile URL through cartoTileUrl(), which appends
?key=. The parameter name matters: api_key= is silently ignored and returns the
same watermarked tile as no key at all, so a wrong guess looks right until the
map is looked at. Verified by checksum that the URLs the map generates return
the clean tiles for both styles.

The key comes from VITE_CARTO_API_KEY in .env.local, which is git-ignored, so it
is not in the source and rotating it is a configuration change; .env.example
documents the variable. If it is missing, a console warning says so rather than
leaving only a watermark to explain it.

The key is inlined into the built bundle in dist/ and sent with every tile
request, so it is visible to anyone using the application. That is expected for
a tile key; the exposure is quota, not access.

Cached watermarked tiles are not a concern: the Service Worker keys tiles by
full URL including the query string, so keyed URLs are fetched fresh and the old
entries age out. No Service Worker bump — HTML is network-first, so the new
bundle is picked up without one, and a bump would delete every offline tile
cache (flagged separately).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:39:26 +02:00
88f55ade46 Status report: record the radius merge, and the projection finding in full
Section 18 described the typed circle radius and the movable analysis popup as
held on a branch awaiting a usability check. They have since been merged and
issued as Service Worker v24, so the section now says so.

  18 intro   Service Worker versions for the period run to v24.
  18.5       Heading no longer "under test". The status paragraph records the
             merge after desktop approval, and the two defects found by testing
             through the controls before it: Escape applying the value it was
             meant to discard, and a typed 200 m stepping by 5 m because the map
             library recomputes the radius a couple of hundred-billionths of a
             metre short. Mobile remains unverified.
  18.6       Adds "Typing a number exposes an error that dragging hides" from the
             Typed Measurements design review, with its paragraph and table
             verbatim, replacing the section's own shorter version of the same
             point. What follows says what was built, including where it departs
             from the review: the review suggested measuring the result with
             ol/sphere, but the same cos(latitude) factor is used both ways so
             the value typed, the radius shown and pi x radius^2 agree exactly.
  18.7       Records the four guide and manual passages corrected after the
             merge, and that the fourth was only found by reading the rendered
             page.
  18.8       The open item is no longer "merge it"; it is the phone check that
             is still outstanding.

August's open items in 17.13 are left as written — they are the record of that
period.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:21:24 +02:00
9840180ba3 After the radius merge: Service Worker v24, and docs that said it could not be done
Service Worker v23 -> v24. The version was held back while the radius adjuster
lived on a branch, so reverting would not strand clients on a newer cache than
main. With the branch merged, deployed clients need the bump to pick up the new
bundle cleanly.

Four documentation passages said a finished circle could not be resized, which
was true when written and is not any more:

  Buffer / Circle guide
    - the call-out saying measurements are read-only
    - the call-out saying there is no box to type a radius into and the
      distance is set by eye
    - the troubleshooting row telling the reader to draw a new circle
  User manual, Part 1
    - the call-out saying to clear a measurement and draw it again

Three were found by searching for the phrases involved. The fourth only turned
up when the page was rendered and read, because it said the same thing in
different words — worth remembering the next time behaviour changes under
existing documentation.

Each correction keeps the point the original was making: the shape still cannot
be dragged out of true, so the figures always describe the circle on the map.
What changed is that there is now a deliberate way to resize it. The manual's
call-out stays accurate for areas, which still cannot be resized.

The guide also gains a paragraph where it describes the label, explaining the
minus, plus and typed entry and that the figures are ground distances; and two
quick-reference rows, for setting an exact radius and for dragging the analysis
panel out of the way. The manual's pointer to the guide no longer mentions the
radius-line handles, which no longer exist.

The manual is re-stamped as describing application version 24, September 2026.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:16:11 +02:00
f4c56e5078 Merge circle-radius-entry: typed circle radius and movable analysis popup
Brings in the radius adjuster on a finished circle's tooltip, the ground-distance
correction for circle measurements, the draggable Circle and Area analysis
popups, and the two fixes found by testing them through their own controls.

Kept on a branch while it waited for a usability check. LUSPA approved it on a
desktop; the touch behaviour on a phone is still unverified, because the remote
server was down, and remains an open item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:12:51 +02:00
7369f62e92 Radius adjuster: Escape really cancels; steps stay round at round numbers
Two defects found by driving the adjuster through its own controls, rather than
calling setCircleRadius() directly as the first test did.

Escape applied the value it was meant to discard. Escape swaps the input back
out for the figure; removing a focused element fires blur synchronously while it
is still connected, and the blur handler commits — so the cancelled value was
applied a moment later. The input now settles exactly once.

A typed 200 stepped by 5 instead of 10. OpenLayers stores a circle as its centre
and a point on the edge and recomputes the radius as (cx + r) - cx, which at map
coordinates around -278,000 comes back about 2e-11 short. 200 read as
199.99999999997, fell below the 200 threshold in radiusStep(), and stepped by the
smaller increment — at exactly the round numbers officers are likely to type,
and in a way that varied with position. The ground radius is now rounded to the
micrometre where it is read, which removes the noise and nothing anyone can see.
The cosine correction itself was checked across 2000 latitudes spanning Ghana
and round-trips exactly; it was not the source.

Checked end to end at Wa: draw through the tool, type through the input, Enter,
Escape, both steppers, a tap on the adjuster not starting a new measurement, the
popup agreeing with the tooltip, dragging the popup, the close button, reopening,
and Clear. 19 of 19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:11:26 +02:00
34cc2563a3 Status report: bring the tracked copy up to date through September
The tracked report had not moved since May and still ended at section 12, the
April update — five months and six sections behind. Revisions 13 to 18 were
written but only ever existed as -v(n) snapshots, which .gitignore excludes by
design: the rule added in 9b57ff9e keeps interim snapshots out of the repository
and says the current report stays tracked. That second half had stopped
happening, so the repository's copy was quietly stale while the real document
moved on outside it.

This replaces the tracked file with the current revision, which now runs to
section 18 (September 2026) and covers the period April to September.

Section 18 records this period's work: the drawing toolbar rebuilt as a docked
left rail, four rounds of correction against a real iPhone, the two ways of
adding a point reduced to one, the Markers-layer defect that had been present
since the beginning, the Web Mercator correction to circle measurements, and the
first part of the user manual alongside the schema summary.

Two things it states plainly rather than glossing. The typed circle radius and
the movable analysis popup are recorded as under test on a branch, not as
shipped — LUSPA confirmed them on a desktop, but the remote server was down and
the touch behaviour is unverified. And 18.2 lists the four iPhone failures with
their causes, because the pattern is more useful for planning than the
individual faults: desktop testing at a phone-sized window verifies layout
arithmetic, but not stylesheet load order in the built application, nor how iOS
paints.

The -v(n) files stay ignored. If those snapshots should be versioned too, the
ignore rule is the thing to change, not this commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 17:43:33 +02:00
90a5a01b05 Drag the analysis popup by its title bar
The popup covers the thing it is reporting on — a circle's own parcels sit
directly underneath it — so it has to be movable to be read against the map.
Pressing the title bar and dragging now moves it.

The drag changes the overlay's offset, not its position. The popup stays tied to
the shape it describes, so panning and zooming carry it along and it goes on
pointing at the right circle; only where it rests relative to that anchor
changes. Setting a new position instead would cut it loose and re-anchor it to
whatever ground it happened to be dropped on.

The resting offset is now a named constant shared with the overlay, so the two
cannot drift apart, and each showing starts from it — a popup opened for a new
shape appears where it belongs rather than where the last one was left.

A press on the close button is not a drag, and the overlay already has
stopEvent:true, so the map does not pan underneath.

This covers the Area Analysis popup too: both are built by the same
_buildAnalysisPopupHtml, so both get the handle.

Separate commit from the radius work so it can be cherry-picked onto main if the
radius entry does not survive the usability check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 17:26:30 +02:00
631b316118 Adjust a measured circle's radius from its tooltip, in ground metres
Officers asked to enter an exact distance rather than drag for it. The tooltip
that appears when the second click lands now carries the adjustment: minus, the
figure itself, plus. Tapping the figure swaps in a numeric input, so an exact
radius can be typed; inputmode="decimal" brings up a keypad rather than a full
keyboard on a phone.

Not a dial. A radius runs from a few metres to several kilometres and no dial
gives usable resolution across that range on a tooltip this size. Stepping snaps
to an increment that grows with the value — 1 m below 50, 5 m below 200, 10 m
below 1 km, 50 m below 5 km — so one tap means something at either end, and
repeated taps land on round numbers instead of carrying the fraction the drag
happened to finish on.

Everything writes through MapTools.setCircleRadius(). Measurements were made
read-only earlier precisely because a draggable handle moved the geometry while
the popup went on reporting the radius captured at draw time; a single path
means the geometry, the radius line, the tooltip and the analysis popup cannot
drift apart again.

The figures are now ground distances. The geometry stores a Web Mercator radius,
which overstates the ground by 1/cos(latitude) — a 200 m circle at Wa was 197 m
on the ground. That was invisible while dragging, because the label and the
shape agreed with each other, but typing 200 is a claim about the ground. The
conversion lives in units.js and is applied in the live tooltip, the adjuster
and the Circle Analysis popup, so all three state the same distance.

Two deliberate choices worth knowing before the usability check:

  - The tooltip is frozen where the draw finished. It is anchored to the
    circumference, so re-positioning on every change would slide the buttons out
    from under the officer's thumb. A large adjustment therefore leaves it
    sitting inside the circle rather than on its edge.
  - No measure-complete event fires on adjustment. Consumers read that as "a new
    measurement was drawn", and stepping would emit a stream of false ones.

The overlay is created with stopEvent:false so the map keeps working through the
label; the buttons stop their own events, or a tap would also pan the map.

Service Worker version deliberately unchanged, so reverting does not leave
clients on a newer cache than main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 17:06:14 +02:00
9b4c7625d2 Add the database schema summary, with two ER diagrams
Condenses the 96-page generated data dictionary into ten pages that can be read
in one sitting: what the four schemas are for, what the table-name prefixes
mean, every table with one line on what it holds, and a set of observations for
the database team. The generated dictionary stays the authority on columns and
types; this is the document to read first.

Two diagrams carry the part the dictionary cannot show. Figure 1 is the four
schemas and the fact that each of the other three has its own name for the
district it refers to — district_id, district_ref, project_district_id — none of
them enforced. Figure 2 is the relationships per schema, drawing the distinction
that matters most: solid arrows are the nine foreign keys the database actually
declares, dashed arrows are joins that exist only because two tables agree.

That distinction produced three findings worth acting on, all in section 3:
permit is the only schema with referential integrity, and even there comments
and stages join on application_code text rather than the id; dataroom's
versioning chain is held together by uuid alone; and the two enforced keys in
spatial point at the legacy parcels table rather than the live lu_parcels, which
is worth checking before either building table is relied on. A fourth is a loose
end — zoning_districts references reference.zoning_types, a schema the
dictionary does not cover at all.

Diagrams are committed as SVG (editable) alongside the PNG the document embeds,
following the pattern already used for the staged-upload and OpenTopography
figures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 12:07:25 +02:00
82 changed files with 2916 additions and 1337 deletions

8
.env.example Normal file
View File

@ -0,0 +1,8 @@
# Copy to .env.local (git-ignored) and fill in. Vite reads VITE_-prefixed
# variables at build time and inlines them into the bundle.
# CARTO basemaps (Carto Light, Carto Dark). Since late August 2026 CARTO stamps
# an "API KEY REQUIRED" watermark into every tile requested without a key — the
# request still succeeds, so a missing key shows up only as the watermark.
# Free key: https://carto.com/basemaps/apikey
VITE_CARTO_API_KEY=

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
LUPMIS2_Schema_ERM.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 KiB

147
LUPMIS2_Schema_ERM.svg Normal file
View File

@ -0,0 +1,147 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1430" viewBox="0 0 1200 1430" font-family="Calibri, Arial, sans-serif">
<!-- Figure 2: how the tables relate, by schema.
Solid = foreign key declared in the database.
Dashed = tables joined on a shared value, with nothing enforcing it.
Sized for a 5.73 in document column (~209 px/in), so 24 px ~ 8 pt. -->
<defs>
<marker id="s1" markerWidth="10" markerHeight="10" refX="8" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#166534"/></marker>
<marker id="s2" markerWidth="10" markerHeight="10" refX="8" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#6B7280"/></marker>
</defs>
<rect width="1200" height="1430" fill="#FFFFFF"/>
<!-- legend -->
<line x1="24" y1="26" x2="96" y2="26" stroke="#166534" stroke-width="3" marker-end="url(#s1)"/>
<text x="110" y="34" font-size="24" fill="#333333">foreign key enforced by the database</text>
<line x1="600" y1="26" x2="672" y2="26" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#s2)"/>
<text x="686" y="34" font-size="24" fill="#333333">joined on a shared value only</text>
<!-- PANEL A: permit -->
<rect x="16" y="62" width="1168" height="430" rx="12" fill="#FAFBFF" stroke="#DCDDE9" stroke-width="2"/>
<text x="40" y="102" font-size="30" font-weight="bold" fill="#1E1A4B">permit</text>
<text x="140" y="102" font-size="23" fill="#6B7280">the only schema with enforced referential integrity</text>
<rect x="40" y="180" width="300" height="80" rx="10" fill="#EEF2FF" stroke="#1E1A4B" stroke-width="3"/>
<text x="62" y="214" font-size="27" font-weight="bold" fill="#1E1A4B">applications</text>
<text x="62" y="244" font-size="22" fill="#6B7280">id · application_code</text>
<rect x="40" y="300" width="300" height="66" rx="10" fill="#FFFFFF" stroke="#9CA3AF" stroke-width="2"/>
<text x="62" y="330" font-size="25" fill="#333333">applicants</text>
<text x="62" y="356" font-size="21" fill="#6B7280">applicant_id</text>
<rect x="40" y="396" width="300" height="66" rx="10" fill="#FFFFFF" stroke="#9CA3AF" stroke-width="2"/>
<text x="62" y="426" font-size="25" fill="#333333">comments · stages</text>
<text x="62" y="452" font-size="21" fill="#6B7280">application_code</text>
<rect x="620" y="126" width="380" height="58" rx="9" fill="#FFFFFF" stroke="#166534" stroke-width="2.5"/>
<text x="642" y="163" font-size="25" fill="#333333">application_agents</text>
<rect x="620" y="196" width="380" height="58" rx="9" fill="#FFFFFF" stroke="#166534" stroke-width="2.5"/>
<text x="642" y="233" font-size="25" fill="#333333">application_consultants</text>
<rect x="620" y="266" width="380" height="58" rx="9" fill="#FFFFFF" stroke="#166534" stroke-width="2.5"/>
<text x="642" y="303" font-size="25" fill="#333333">application_documents</text>
<rect x="620" y="336" width="380" height="58" rx="9" fill="#FFFFFF" stroke="#166534" stroke-width="2.5"/>
<text x="642" y="373" font-size="25" fill="#333333">application_status_history</text>
<rect x="620" y="406" width="380" height="58" rx="9" fill="#FFFFFF" stroke="#166534" stroke-width="2.5"/>
<text x="642" y="443" font-size="25" fill="#333333">mining_applications</text>
<path d="M340 208 C 470 208, 480 155, 616 155" fill="none" stroke="#166534" stroke-width="3" marker-end="url(#s1)"/>
<path d="M340 214 C 470 214, 480 225, 616 225" fill="none" stroke="#166534" stroke-width="3" marker-end="url(#s1)"/>
<path d="M340 220 C 470 220, 480 295, 616 295" fill="none" stroke="#166534" stroke-width="3" marker-end="url(#s1)"/>
<path d="M340 226 C 470 226, 480 365, 616 365" fill="none" stroke="#166534" stroke-width="3" marker-end="url(#s1)"/>
<path d="M340 232 C 470 232, 480 435, 616 435" fill="none" stroke="#166534" stroke-width="3" marker-end="url(#s1)"/>
<text x="352" y="186" font-size="21" fill="#166534" stroke="#FAFBFF" stroke-width="7" paint-order="stroke" stroke-linejoin="round">application_id</text>
<path d="M190 300 L 190 264" fill="none" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#s2)"/>
<path d="M190 396 C 120 396, 120 300, 176 268" fill="none" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#s2)"/>
<text x="1012" y="256" font-size="22" font-weight="bold" fill="#166534">ON DELETE</text>
<text x="1012" y="284" font-size="22" font-weight="bold" fill="#166534">CASCADE</text>
<text x="1012" y="322" font-size="22" fill="#6B7280">deleting an</text>
<text x="1012" y="350" font-size="22" fill="#6B7280">application</text>
<text x="1012" y="378" font-size="22" fill="#6B7280">removes all five</text>
<!-- PANEL B: dataroom -->
<rect x="16" y="512" width="1168" height="300" rx="12" fill="#FAFBFF" stroke="#DCDDE9" stroke-width="2"/>
<text x="40" y="552" font-size="30" font-weight="bold" fill="#1E1A4B">dataroom</text>
<text x="180" y="552" font-size="23" fill="#6B7280">a versioning chain held together by uuid alone</text>
<rect x="40" y="590" width="250" height="72" rx="10" fill="#EEF2FF" stroke="#1E1A4B" stroke-width="3"/>
<text x="62" y="622" font-size="25" font-weight="bold" fill="#1E1A4B">datasets</text>
<text x="62" y="648" font-size="21" fill="#6B7280">the catalogue entry</text>
<rect x="342" y="590" width="250" height="72" rx="10" fill="#FFFFFF" stroke="#9CA3AF" stroke-width="2"/>
<text x="364" y="622" font-size="25" fill="#333333">dataset_versions</text>
<text x="364" y="648" font-size="21" fill="#6B7280">open or finalised</text>
<rect x="644" y="590" width="250" height="72" rx="10" fill="#FFFFFF" stroke="#9CA3AF" stroke-width="2"/>
<text x="666" y="622" font-size="25" fill="#333333">dataset_resources</text>
<text x="666" y="648" font-size="21" fill="#6B7280">the files · sha256</text>
<rect x="946" y="590" width="238" height="72" rx="10" fill="#FFFFFF" stroke="#9CA3AF" stroke-width="2"/>
<text x="968" y="622" font-size="25" fill="#333333">downloads</text>
<text x="968" y="648" font-size="21" fill="#6B7280">hashed requester</text>
<path d="M290 626 L 338 626" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#s2)"/>
<path d="M592 626 L 640 626" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#s2)"/>
<path d="M894 626 L 942 626" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#s2)"/>
<text x="292" y="690" font-size="21" fill="#6B7280">dataset_id</text>
<text x="594" y="690" font-size="21" fill="#6B7280">version_id</text>
<text x="896" y="690" font-size="21" fill="#6B7280">resource_id</text>
<rect x="40" y="716" width="552" height="66" rx="10" fill="#FFFFFF" stroke="#9CA3AF" stroke-width="2"/>
<text x="62" y="746" font-size="25" fill="#333333">audit_log</text>
<text x="62" y="772" font-size="21" fill="#6B7280">status changes, by dataset and version</text>
<text x="620" y="748" font-size="22" fill="#6B7280">datasets.current_version_id points back</text>
<text x="620" y="776" font-size="22" fill="#6B7280">to the version being served.</text>
<!-- PANEL C: spatial -->
<rect x="16" y="832" width="1168" height="580" rx="12" fill="#F7FDF9" stroke="#DCDDE9" stroke-width="2"/>
<text x="40" y="872" font-size="30" font-weight="bold" fill="#166534">spatial</text>
<text x="140" y="872" font-size="23" fill="#6B7280">scoped by districtid; parcels keyed by UPN</text>
<text x="40" y="920" font-size="23" font-weight="bold" fill="#1E1A4B">Administrative hierarchy</text>
<rect x="40" y="936" width="238" height="62" rx="10" fill="#FFFFFF" stroke="#9CA3AF" stroke-width="2"/>
<text x="62" y="974" font-size="24" fill="#333333">area_region</text>
<rect x="330" y="936" width="238" height="62" rx="10" fill="#EEF2FF" stroke="#1E1A4B" stroke-width="3"/>
<text x="352" y="974" font-size="24" font-weight="bold" fill="#1E1A4B">area_districts</text>
<rect x="620" y="936" width="272" height="62" rx="10" fill="#FFFFFF" stroke="#9CA3AF" stroke-width="2"/>
<text x="642" y="974" font-size="24" fill="#333333">area_collectorzones</text>
<rect x="944" y="936" width="240" height="62" rx="10" fill="#FFFFFF" stroke="#9CA3AF" stroke-width="2"/>
<text x="966" y="974" font-size="24" fill="#333333">area_gra</text>
<path d="M278 967 L 326 967" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#s2)"/>
<path d="M568 967 L 616 967" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#s2)"/>
<path d="M892 967 L 940 967" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#s2)"/>
<text x="272" y="1022" font-size="21" fill="#6B7280">regionid</text>
<text x="560" y="1022" font-size="21" fill="#6B7280">districtid</text>
<text x="884" y="1022" font-size="21" fill="#6B7280">tscid</text>
<text x="40" y="1076" font-size="23" font-weight="bold" fill="#1E1A4B">Parcels — the live tables</text>
<rect x="40" y="1092" width="250" height="62" rx="10" fill="#FFFFFF" stroke="#9CA3AF" stroke-width="2"/>
<text x="62" y="1130" font-size="24" fill="#333333">gh_upn_grid</text>
<rect x="342" y="1092" width="250" height="62" rx="10" fill="#EEF2FF" stroke="#1E1A4B" stroke-width="3"/>
<text x="364" y="1130" font-size="24" font-weight="bold" fill="#1E1A4B">lu_parcels</text>
<rect x="644" y="1092" width="250" height="62" rx="10" fill="#FFFFFF" stroke="#9CA3AF" stroke-width="2"/>
<text x="666" y="1130" font-size="24" fill="#333333">lu_buildings</text>
<rect x="342" y="1180" width="250" height="62" rx="10" fill="#FFFFFF" stroke="#9CA3AF" stroke-width="2"/>
<text x="364" y="1218" font-size="24" fill="#333333">…_upload_tmp</text>
<path d="M290 1123 L 338 1123" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#s2)"/>
<path d="M592 1123 L 640 1123" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#s2)"/>
<path d="M467 1180 L 467 1158" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#s2)"/>
<text x="252" y="1176" font-size="21" fill="#6B7280">issues upn</text>
<text x="600" y="1176" font-size="21" fill="#6B7280">upn</text>
<text x="612" y="1222" font-size="22" fill="#6B7280">promoted by</text>
<text x="612" y="1248" font-size="22" fill="#6B7280">transfer_upload_to_master_json</text>
<text x="40" y="1300" font-size="23" font-weight="bold" fill="#1E1A4B">Legacy tables — where the only enforced keys are</text>
<rect x="40" y="1316" width="250" height="62" rx="10" fill="#FFFFFF" stroke="#166534" stroke-width="2.5"/>
<text x="62" y="1354" font-size="24" fill="#333333">parcels</text>
<rect x="440" y="1316" width="250" height="62" rx="10" fill="#FFFFFF" stroke="#166534" stroke-width="2.5"/>
<text x="462" y="1354" font-size="24" fill="#333333">buildings</text>
<rect x="742" y="1316" width="250" height="62" rx="10" fill="#FFFFFF" stroke="#166534" stroke-width="2.5"/>
<text x="764" y="1354" font-size="24" fill="#333333">pi_buildings</text>
<path d="M436 1347 L 294 1347" stroke="#166534" stroke-width="3" marker-end="url(#s1)"/>
<path d="M742 1390 C 600 1400, 320 1400, 200 1382" fill="none" stroke="#166534" stroke-width="3" marker-end="url(#s1)"/>
<text x="300" y="1330" font-size="21" fill="#166534">parcel_id</text>
<text x="1012" y="1354" font-size="22" fill="#6B7280">not lu_parcels</text>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

BIN
LUPMIS2_Schema_Map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

61
LUPMIS2_Schema_Map.svg Normal file
View File

@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="620" viewBox="0 0 1200 620" font-family="Calibri, Arial, sans-serif">
<!-- Figure 1: the four schemas and the identifier they share.
Sized for a 5.73 in document column (~209 px/in), so 26 px ~ 9 pt. -->
<defs>
<marker id="d1" markerWidth="10" markerHeight="10" refX="8" refY="4" orient="auto">
<path d="M0,0 L8,4 L0,8 Z" fill="#6B7280"/></marker>
</defs>
<rect width="1200" height="620" fill="#FFFFFF"/>
<!-- the three record-keeping schemas -->
<rect x="24" y="40" width="410" height="140" rx="12" fill="#F5F7FF" stroke="#1E1A4B" stroke-width="3"/>
<text x="48" y="84" font-size="32" font-weight="bold" fill="#1E1A4B">admin</text>
<text x="168" y="84" font-size="24" fill="#6B7280">13 tables · 28 functions</text>
<text x="48" y="122" font-size="25" fill="#333333">Users, roles, menus, privileges,</text>
<text x="48" y="154" font-size="25" fill="#333333">API tokens, access logging.</text>
<rect x="24" y="216" width="410" height="140" rx="12" fill="#F5F7FF" stroke="#1E1A4B" stroke-width="3"/>
<text x="48" y="260" font-size="32" font-weight="bold" fill="#1E1A4B">dataroom</text>
<text x="212" y="260" font-size="24" fill="#6B7280">5 tables · 18 functions</text>
<text x="48" y="298" font-size="25" fill="#333333">Published datasets, versions,</text>
<text x="48" y="330" font-size="25" fill="#333333">licensing, download tracking.</text>
<rect x="24" y="392" width="410" height="140" rx="12" fill="#F5F7FF" stroke="#1E1A4B" stroke-width="3"/>
<text x="48" y="436" font-size="32" font-weight="bold" fill="#1E1A4B">permit</text>
<text x="168" y="436" font-size="24" fill="#6B7280">10 tables · 43 functions</text>
<text x="48" y="474" font-size="25" fill="#333333">Development and mining permit</text>
<text x="48" y="506" font-size="25" fill="#333333">applications and documents.</text>
<!-- spatial -->
<rect x="676" y="40" width="500" height="492" rx="12" fill="#F0FDF4" stroke="#166534" stroke-width="3"/>
<text x="704" y="84" font-size="32" font-weight="bold" fill="#166534">spatial</text>
<text x="836" y="84" font-size="24" fill="#6B7280">32 tables · 30 functions</text>
<text x="704" y="122" font-size="25" fill="#333333">All mapping data. The schema the</text>
<text x="704" y="154" font-size="25" fill="#333333">field application reads.</text>
<rect x="704" y="184" width="444" height="86" rx="10" fill="#FFFFFF" stroke="#166534" stroke-width="2.5"/>
<text x="726" y="218" font-size="26" font-weight="bold" fill="#166534">area_districts</text>
<text x="726" y="252" font-size="23" fill="#333333">the district register — districtid</text>
<text x="704" y="316" font-size="24" fill="#6B7280">Everything else in spatial is scoped by</text>
<text x="704" y="346" font-size="24" fill="#6B7280">districtid: parcels, zones, roads,</text>
<text x="704" y="376" font-size="24" fill="#6B7280">facilities, contours, the UPN grid.</text>
<rect x="704" y="404" width="444" height="104" rx="10" fill="#FFFFFF" stroke="#9CA3AF" stroke-width="2" stroke-dasharray="7 5"/>
<text x="726" y="440" font-size="24" fill="#333333">One foreign key leaves the four</text>
<text x="726" y="470" font-size="24" fill="#333333">schemas entirely: zoning_districts</text>
<text x="726" y="498" font-size="24" fill="#333333">→ reference.zoning_types.</text>
<!-- the shared identifier -->
<path d="M434 110 C 540 110, 560 210, 700 222" fill="none" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#d1)"/>
<path d="M434 286 C 540 286, 560 230, 700 227" fill="none" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#d1)"/>
<path d="M434 462 C 540 462, 560 250, 700 234" fill="none" stroke="#6B7280" stroke-width="2.5" stroke-dasharray="8 6" marker-end="url(#d1)"/>
<text x="452" y="128" font-size="22" fill="#6B7280" stroke="#FFFFFF" stroke-width="7" paint-order="stroke" stroke-linejoin="round">district_id</text>
<text x="452" y="272" font-size="22" fill="#6B7280" stroke="#FFFFFF" stroke-width="7" paint-order="stroke" stroke-linejoin="round">district_ref</text>
<text x="452" y="448" font-size="22" fill="#6B7280" stroke="#FFFFFF" stroke-width="7" paint-order="stroke" stroke-linejoin="round">project_district_id</text>
<!-- footnote -->
<line x1="24" y1="562" x2="1176" y2="562" stroke="#DCDDE9" stroke-width="2"/>
<text x="24" y="596" font-size="23" fill="#6B7280">Dashed = joined by shared value, not by an enforced constraint. No foreign key crosses a schema boundary.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

8
dist/.htaccess vendored
View File

@ -70,6 +70,14 @@ DirectoryIndex index.php index.html
# index.php. Query strings (?mode=permit&...) pass through automatically.
RewriteRule ^embed/?$ embed.php [L]
# A direct request for /index.html must still go through index.php, which
# is what injects window.LUPMIS_SESSION. Without this rule the SPA fallback
# below cannot help: its RewriteCond !-f fails precisely because index.html
# IS a real file, so Apache serves it raw and the app starts with no
# session — the "No session injected" warning. index.php reads index.html
# from the filesystem (__DIR__), not over HTTP, so there is no loop.
RewriteRule ^index\.html$ index.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.

12
dist/assets/analysis-modal-BJxejC9Q.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/geotiff-B_Wfowsp.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

933
dist/assets/index-ybgDODsx.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-ybgDODsx.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
dist/assets/pako-n3Pgozwg.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/pako-n3Pgozwg.js.map vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
import{E as f,a as b}from"./jspdf-FJgf4ijI.js";import"./pako-Xa-UToif.js";import"./geotiff-BaoeLn6q.js";import"./openlayers-J9qS6Th1.js";b(f);let s=null;async function x(){if(s)return s;try{const e=new Image;e.crossOrigin="anonymous",await new Promise((r,i)=>{e.onload=r,e.onerror=i,e.src="./app-icons/luspa-pdf.jpg"});const n=document.createElement("canvas");n.width=e.naturalWidth,n.height=e.naturalHeight;const t=n.getContext("2d");return t.fillStyle="#ffffff",t.fillRect(0,0,n.width,n.height),t.drawImage(e,0,0),s=n.toDataURL("image/jpeg",.92),s}catch(e){return console.warn("[PDF] Could not load logo:",e),null}}async function F({title:e,rows:n}){const t=new f({orientation:"portrait",unit:"mm",format:"a4"}),r=t.internal.pageSize.getWidth(),i=[30,26,75],g=await x(),c=28,a=14;let o=14;g&&t.addImage(g,"JPEG",a,o,c,c);const m=a+c+6;t.setFont("helvetica","bold"),t.setFontSize(18),t.setTextColor(...i),t.text("LUPMIS",m,o+11),t.setFont("helvetica","normal"),t.setFontSize(12),t.text(e,m,o+19);const d=new Date,h=d.toLocaleDateString(void 0,{year:"numeric",month:"long",day:"numeric"}),p=d.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});t.setFontSize(9),t.setTextColor(120,120,120),t.text(`${h} ${p}`,r-a,o+11,{align:"right"}),o+=c+6,t.setDrawColor(...i),t.setLineWidth(.5),t.line(a,o,r-a,o),o+=6;const y=n.map(l=>[l.label,l.value]);t.autoTable({startY:o,head:[["Property","Value"]],body:y,margin:{left:a,right:a},styles:{font:"helvetica",fontSize:10,cellPadding:4},headStyles:{fillColor:i,textColor:[255,255,255],fontStyle:"bold"},alternateRowStyles:{fillColor:[245,245,250]},columnStyles:{0:{fontStyle:"bold",cellWidth:50}}});const S=t.lastAutoTable.finalY+10;t.setFontSize(8),t.setTextColor(160,160,160),t.text("Generated by LUPMIS2 Land Use Planning & Management Information System",a,S);const w=t.output("blob"),u=URL.createObjectURL(w);if(!window.open(u,"_blank")){const l=document.createElement("a");l.href=u,l.download=`${e.replace(/\s+/g,"_")}_${d.toISOString().slice(0,10)}.pdf`,document.body.appendChild(l),l.click(),document.body.removeChild(l)}}export{F as exportAnalysisPDF};
//# sourceMappingURL=pdf-export-BONdNTHD.js.map

2
dist/assets/pdf-export-DJBZ6C0H.js vendored Normal file
View File

@ -0,0 +1,2 @@
import{E as f,a as b}from"./jspdf-HFui8oS7.js";import"./pako-n3Pgozwg.js";import"./vite-helpers-BlTxHScW.js";b(f);let s=null;async function x(){if(s)return s;try{const e=new Image;e.crossOrigin="anonymous",await new Promise((r,i)=>{e.onload=r,e.onerror=i,e.src="./app-icons/luspa-pdf.jpg"});const n=document.createElement("canvas");n.width=e.naturalWidth,n.height=e.naturalHeight;const t=n.getContext("2d");return t.fillStyle="#ffffff",t.fillRect(0,0,n.width,n.height),t.drawImage(e,0,0),s=n.toDataURL("image/jpeg",.92),s}catch(e){return console.warn("[PDF] Could not load logo:",e),null}}async function D({title:e,rows:n}){const t=new f({orientation:"portrait",unit:"mm",format:"a4"}),r=t.internal.pageSize.getWidth(),i=[30,26,75],g=await x(),c=28,a=14;let o=14;g&&t.addImage(g,"JPEG",a,o,c,c);const m=a+c+6;t.setFont("helvetica","bold"),t.setFontSize(18),t.setTextColor(...i),t.text("LUPMIS",m,o+11),t.setFont("helvetica","normal"),t.setFontSize(12),t.text(e,m,o+19);const d=new Date,h=d.toLocaleDateString(void 0,{year:"numeric",month:"long",day:"numeric"}),p=d.toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"});t.setFontSize(9),t.setTextColor(120,120,120),t.text(`${h} ${p}`,r-a,o+11,{align:"right"}),o+=c+6,t.setDrawColor(...i),t.setLineWidth(.5),t.line(a,o,r-a,o),o+=6;const y=n.map(l=>[l.label,l.value]);t.autoTable({startY:o,head:[["Property","Value"]],body:y,margin:{left:a,right:a},styles:{font:"helvetica",fontSize:10,cellPadding:4},headStyles:{fillColor:i,textColor:[255,255,255],fontStyle:"bold"},alternateRowStyles:{fillColor:[245,245,250]},columnStyles:{0:{fontStyle:"bold",cellWidth:50}}});const S=t.lastAutoTable.finalY+10;t.setFontSize(8),t.setTextColor(160,160,160),t.text("Generated by LUPMIS2 Land Use Planning & Management Information System",a,S);const w=t.output("blob"),u=URL.createObjectURL(w);if(!window.open(u,"_blank")){const l=document.createElement("a");l.href=u,l.download=`${e.replace(/\s+/g,"_")}_${d.toISOString().slice(0,10)}.pdf`,document.body.appendChild(l),l.click(),document.body.removeChild(l)}}export{D as exportAnalysisPDF};
//# sourceMappingURL=pdf-export-DJBZ6C0H.js.map

File diff suppressed because one or more lines are too long

5
dist/assets/shpjs-CNrRgkgn.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/shpjs-CNrRgkgn.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
dist/assets/vite-helpers-BlTxHScW.js vendored Normal file
View File

@ -0,0 +1,2 @@
const p="modulepreload",v=function(l){return"/"+l},d={},y=function(f,c,E){let i=Promise.resolve();if(c&&c.length>0){let r=function(e){return Promise.all(e.map(o=>Promise.resolve(o).then(s=>({status:"fulfilled",value:s}),s=>({status:"rejected",reason:s}))))};document.getElementsByTagName("link");const t=document.querySelector("meta[property=csp-nonce]"),u=t?.nonce||t?.getAttribute("nonce");i=r(c.map(e=>{if(e=v(e),e in d)return;d[e]=!0;const o=e.endsWith(".css"),s=o?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${e}"]${s}`))return;const n=document.createElement("link");if(n.rel=o?"stylesheet":p,o||(n.as="script"),n.crossOrigin="",n.href=e,u&&n.setAttribute("nonce",u),document.head.appendChild(n),o)return new Promise((m,h)=>{n.addEventListener("load",m),n.addEventListener("error",()=>h(new Error(`Unable to preload CSS for ${e}`)))})}))}function a(r){const t=new Event("vite:preloadError",{cancelable:!0});if(t.payload=r,window.dispatchEvent(t),!t.defaultPrevented)throw r}return i.then(r=>{for(const t of r||[])t.status==="rejected"&&a(t.reason);return f().catch(a)})};export{y as _};
//# sourceMappingURL=vite-helpers-BlTxHScW.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"vite-helpers-BlTxHScW.js","sources":[],"sourcesContent":[],"names":[],"mappings":""}

View File

@ -1,30 +0,0 @@
function u(n){return String(n).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g,"")}function g(n){let o="",e=n;for(;e>=0;)o=String.fromCharCode(e%26+65)+o,e=Math.floor(e/26)-1;return o}function F(n){return n&&!Array.isArray(n)&&Array.isArray(n.cells)?{cells:n.cells,bold:!!n.bold}:{cells:Array.isArray(n)?n:[n],bold:!1}}function b(n){const o=[];return n.forEach((e,r)=>{const{cells:l,bold:c}=F(e),i=c?' s="1"':"",m=l.map((t,a)=>{const f=`${g(a)}${r+1}`;return t==null||t===""?`<c r="${f}"${i}/>`:typeof t=="number"&&Number.isFinite(t)?`<c r="${f}"${i}><v>${t}</v></c>`:`<c r="${f}"${i} t="inlineStr"><is><t xml:space="preserve">${u(t)}</t></is></c>`}).join("");o.push(`<row r="${r+1}">${m}</row>`)}),`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${o.join("")}</sheetData></worksheet>`}const k=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
</styleSheet>`,T=(()=>{const n=new Uint32Array(256);for(let o=0;o<256;o++){let e=o;for(let r=0;r<8;r++)e=e&1?3988292384^e>>>1:e>>>1;n[o]=e>>>0}return n})();function I(n){let o=4294967295;for(let e=0;e<n.length;e++)o=T[(o^n[e])&255]^o>>>8;return(o^4294967295)>>>0}function $(n){const o=new TextEncoder,e=Object.entries(n).map(([s,p])=>({name:s,nameBytes:o.encode(s),data:o.encode(p)})),r=0,l=20513,c=[],i=[];let m=0;const t=s=>[s&255,s>>>8&255],a=s=>[s&255,s>>>8&255,s>>>16&255,s>>>24&255];for(const s of e){const p=I(s.data),d=s.data.length,x=[...a(67324752),...t(20),...t(0),...t(0),...t(r),...t(l),...a(p),...a(d),...a(d),...t(s.nameBytes.length),...t(0)];c.push(new Uint8Array(x),s.nameBytes,s.data),i.push([...a(33639248),...t(20),...t(20),...t(0),...t(0),...t(r),...t(l),...a(p),...a(d),...a(d),...t(s.nameBytes.length),...t(0),...t(0),...t(0),...t(0),...a(0),...a(m)]),i.push(s.nameBytes),m+=x.length+s.nameBytes.length+d}const f=[];let h=0;for(const s of i){const p=s instanceof Uint8Array?s:new Uint8Array(s);f.push(p),h+=p.length}const y=new Uint8Array([...a(101010256),...t(0),...t(0),...t(e.length),...t(e.length),...a(h),...a(m),...t(0)]);return new Blob([...c,...f,y],{type:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"})}function w(n){const o=(n||[]).filter(l=>l&&Array.isArray(l.rows));if(o.length===0)throw new Error("No sheets to export.");const e={},r=o.map((l,c)=>{const i=`xl/worksheets/sheet${c+1}.xml`;e[i]=b(l.rows);const m=String(l.name||`Sheet${c+1}`).replace(/[:\\/?*[\]]/g," ").slice(0,31);return{id:c+1,name:m,file:i}});return e["[Content_Types].xml"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
${r.map(l=>`<Override PartName="/${l.file}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join(`
`)}
</Types>`,e["_rels/.rels"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`,e["xl/workbook.xml"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>${r.map(l=>`<sheet name="${u(l.name)}" sheetId="${l.id}" r:id="rId${l.id}"/>`).join("")}</sheets>
</workbook>`,e["xl/_rels/workbook.xml.rels"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
${r.map(l=>`<Relationship Id="rId${l.id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${l.id}.xml"/>`).join(`
`)}
<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`,e["xl/styles.xml"]=k,$(e)}function S(n,o){const e=URL.createObjectURL(n),r=document.createElement("a");r.href=e,r.download=o,document.body.appendChild(r),r.click(),r.remove(),setTimeout(()=>URL.revokeObjectURL(e),1e3)}function U(n,o){S(w(n),o)}export{w as buildXlsxBlob,S as downloadBlob,U as exportXlsx};
//# sourceMappingURL=xlsx-d1LrY7GM.js.map

File diff suppressed because one or more lines are too long

30
dist/assets/xlsx-x-C7b9wf.js vendored Normal file
View File

@ -0,0 +1,30 @@
import{d as f,z as d}from"./index-ybgDODsx.js";import"./vite-helpers-BlTxHScW.js";import"./bootstrap-D1-uvFxm.js";import"./openlayers-D5-VSKzq.js";import"./ol-ext-DV70ljNY.js";function p(e){return String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g,"")}function h(e){let l="",o=e;for(;o>=0;)l=String.fromCharCode(o%26+65)+l,o=Math.floor(o/26)-1;return l}function x(e){return e&&!Array.isArray(e)&&Array.isArray(e.cells)?{cells:e.cells,bold:!!e.bold}:{cells:Array.isArray(e)?e:[e],bold:!1}}function u(e){const l=[];return e.forEach((o,n)=>{const{cells:t,bold:a}=x(o),r=a?' s="1"':"",i=t.map((s,c)=>{const m=`${h(c)}${n+1}`;return s==null||s===""?`<c r="${m}"${r}/>`:typeof s=="number"&&Number.isFinite(s)?`<c r="${m}"${r}><v>${s}</v></c>`:`<c r="${m}"${r} t="inlineStr"><is><t xml:space="preserve">${p(s)}</t></is></c>`}).join("");l.push(`<row r="${n+1}">${i}</row>`)}),`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${l.join("")}</sheetData></worksheet>`}const y=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
</styleSheet>`;function g(e){const l=(e||[]).filter(t=>t&&Array.isArray(t.rows));if(l.length===0)throw new Error("No sheets to export.");const o={},n=l.map((t,a)=>{const r=`xl/worksheets/sheet${a+1}.xml`;o[r]=u(t.rows);const i=String(t.name||`Sheet${a+1}`).replace(/[:\\/?*[\]]/g," ").slice(0,31);return{id:a+1,name:i,file:r}});return o["[Content_Types].xml"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
${n.map(t=>`<Override PartName="/${t.file}" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join(`
`)}
</Types>`,o["_rels/.rels"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`,o["xl/workbook.xml"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>${n.map(t=>`<sheet name="${p(t.name)}" sheetId="${t.id}" r:id="rId${t.id}"/>`).join("")}</sheets>
</workbook>`,o["xl/_rels/workbook.xml.rels"]=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
${n.map(t=>`<Relationship Id="rId${t.id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${t.id}.xml"/>`).join(`
`)}
<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`,o["xl/styles.xml"]=y,d(o,"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}function S(e,l){f(g(e),l)}export{g as buildXlsxBlob,S as exportXlsx};
//# sourceMappingURL=xlsx-x-C7b9wf.js.map

1
dist/assets/xlsx-x-C7b9wf.js.map vendored Normal file

File diff suppressed because one or more lines are too long

12
dist/embed.php vendored
View File

@ -35,7 +35,17 @@ header_remove('X-Frame-Options');
// ────────────────────────────────────────────────────────────────────────────
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);
// The SSO service is reached by its container name on the internal Docker
// network. The public hostname must NOT be used from inside the server:
// the Lands Commission's NAT rule blocks traffic that leaves the server and
// tries to come back in, so a call to https://lupmis4luspa.org/ from here
// hangs until CURLOPT_TIMEOUT and then fails validation, bouncing the user
// back to SSO. Measured from inside the spdrawing container on 2026-10-12:
// http://l4lmainbackend/sso/validate answers in 25 ms with no redirect,
// while the public name times out. Override with LUPMIS_SSO_BASE if the
// container is renamed.
$ssoBase = getenv('LUPMIS_SSO_BASE') ?: 'http://l4lmainbackend';
$validate_url = rtrim($ssoBase, '/') . '/sso/validate?token=' . urlencode($plainToken);
$curl = curl_init();
curl_setopt_array($curl, [

107
dist/index.html vendored
View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#005eb8">
<meta name="description" content="LUPMIS2 Drawing Tools">
<meta name="description" content="LUPMIS2 GIS Tools">
<!-- PWA Manifest -->
<link rel="manifest" href="manifest.json">
@ -61,7 +61,7 @@
}
</style>
<title>LUPMIS2 Drawing Tools</title>
<title>LUPMIS2 GIS Tools</title>
<style>
/* LUSPA Design System Tokens
@ -1443,6 +1443,86 @@
border-top-color: var(--primary);
}
/* ----------------------------------------------------------------
Radius adjuster on a finished circle's tooltip.
The label becomes a control: minus, the figure (tap to type), plus.
Targets are 32px so they can be hit with a thumb; the tooltip is
frozen where the draw finished so the buttons do not slide away as
the radius changes.
---------------------------------------------------------------- */
.measure-tooltip-adjust {
padding: 5px 6px;
cursor: default;
/* The overlay is created with stopEvent:false so the map keeps working
through the label. The buttons need the events, so re-enable them
here and let the JS stop them reaching the map. */
pointer-events: auto;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.measure-tooltip-adjust .mt-row {
display: flex;
align-items: center;
gap: 4px;
}
.measure-tooltip-adjust .mt-step,
.measure-tooltip-adjust .mt-value {
font-family: var(--font-body);
font-weight: 600;
color: inherit;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 6px;
cursor: pointer;
padding: 0;
}
.measure-tooltip-adjust .mt-step {
width: 32px;
height: 32px;
font-size: 18px;
line-height: 1;
flex: none;
}
.measure-tooltip-adjust .mt-value {
min-width: 92px;
height: 32px;
padding: 0 10px;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.measure-tooltip-adjust .mt-step:hover,
.measure-tooltip-adjust .mt-value:hover {
background: rgba(255, 255, 255, 0.3);
}
.measure-tooltip-adjust .mt-step:active {
transform: scale(0.94);
}
.measure-tooltip-adjust .mt-step:focus-visible,
.measure-tooltip-adjust .mt-value:focus-visible {
outline: 2px solid #fff;
outline-offset: 1px;
}
.measure-tooltip-adjust .mt-input {
width: 92px;
height: 32px;
padding: 0 8px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--foreground);
background: #fff;
border: 2px solid var(--primary);
border-radius: 6px;
}
.measure-tooltip-adjust .mt-area {
font-size: 11px;
font-weight: 500;
opacity: 0.85;
text-align: center;
margin-top: 3px;
}
/* Arrow pointing to measurement point */
.measure-tooltip::before {
content: '';
@ -1735,8 +1815,15 @@
block to the rail itself, so `left: 100%` clears the whole panel. The
flyout then always appears at the same place (rail's right edge, centred)
instead of jumping to whichever button was pressed.
The selector needs three classes plus `.ol-control` to beat ol-ext's own
`.ol-control.ol-bar .ol-control { position: relative }`. With one class
fewer the two rules tie on specificity, source order decides, and
ol-ext's stylesheet is injected after this block — so the controls stayed
`relative`, the flyout was positioned against the Select button instead
of the rail, and it covered the Transform button in the second column.
---------------------------------------------------------------- */
.ol-editbar.ol-editbar-rail > .ol-control {
.ol-control.ol-bar.ol-editbar.ol-editbar-rail > .ol-control {
position: static;
}
.ol-editbar-rail .ol-option-bar {
@ -1917,15 +2004,13 @@
}
</style>
<script type="module" crossorigin src="/assets/index-ErUYnuzo.js"></script>
<link rel="modulepreload" crossorigin href="/assets/openlayers-J9qS6Th1.js">
<link rel="modulepreload" crossorigin href="/assets/pako-Xa-UToif.js">
<link rel="modulepreload" crossorigin href="/assets/geotiff-BaoeLn6q.js">
<script type="module" crossorigin src="/assets/index-ybgDODsx.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vite-helpers-BlTxHScW.js">
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
<link rel="modulepreload" crossorigin href="/assets/ol-ext-C5frl4dY.js">
<link rel="modulepreload" crossorigin href="/assets/shpjs-Doisr2ug.js">
<link rel="stylesheet" crossorigin href="/assets/openlayers-BtPuoxOl.css">
<link rel="modulepreload" crossorigin href="/assets/openlayers-D5-VSKzq.js">
<link rel="modulepreload" crossorigin href="/assets/ol-ext-DV70ljNY.js">
<link rel="stylesheet" crossorigin href="/assets/bootstrap-BtmJYOxZ.css">
<link rel="stylesheet" crossorigin href="/assets/openlayers-BtPuoxOl.css">
<link rel="stylesheet" crossorigin href="/assets/ol-ext-BgKrOIxx.css">
<link rel="stylesheet" crossorigin href="/assets/index-Dp-9_Fz_.css">
</head>
@ -1935,7 +2020,7 @@
<nav class="navbar py-2">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">
<span class="me-2">🌍</span><span class="brand-text">LUPMIS2 Drawing Tools</span>
<span class="me-2">🌍</span><span class="brand-text">LUPMIS2 GIS Tools</span>
</span>
<!-- Live GPS status: lon/lat, accuracy (precision) and satellites.

12
dist/index.php vendored
View File

@ -69,7 +69,17 @@ if (isset($_GET['logout'])) {
// cleared the SSO cookie, which left the stale district pinned).
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);
// The SSO service is reached by its container name on the internal Docker
// network. The public hostname must NOT be used from inside the server:
// the Lands Commission's NAT rule blocks traffic that leaves the server and
// tries to come back in, so a call to https://lupmis4luspa.org/ from here
// hangs until CURLOPT_TIMEOUT and then fails validation, bouncing the user
// back to SSO. Measured from inside the spdrawing container on 2026-10-12:
// http://l4lmainbackend/sso/validate answers in 25 ms with no redirect,
// while the public name times out. Override with LUPMIS_SSO_BASE if the
// container is renamed.
$ssoBase = getenv('LUPMIS_SSO_BASE') ?: 'http://l4lmainbackend';
$validate_url = rtrim($ssoBase, '/') . '/sso/validate?token=' . urlencode($plainToken);
$curl = curl_init();
curl_setopt_array($curl, [

2
dist/manifest.json vendored
View File

@ -1,5 +1,5 @@
{
"name": "LUPMIS2 Drawing Tools",
"name": "LUPMIS2 GIS Tools",
"short_name": "LUPMIS2",
"description": "Map and GIS functions for Land Use Planning in Ghana",
"start_url": "./",

195
dist/sw.js vendored
View File

@ -130,7 +130,61 @@
// not only in the moment a location is saved. Previously a reload put the
// layer back to hidden and every marker vanished. An empty district still
// opens with the layer off.
const CACHE_VERSION = 'v23';
// v24: Circle measurements can be resized after drawing: the tooltip on a
// finished circle carries minus, the figure (tap to type an exact radius)
// and plus, stepping by increments that grow with the size. Circle radius
// and area are now reported as ground distance rather than projected Web
// Mercator metres, which overstated them by 1/cos(latitude) — about 3 m on
// a 200 m circle at Wa. The Circle and Area analysis popups can be dragged
// by their title bar, staying attached to the shape they describe.
// v25: Releases no longer delete offline map tiles. Tile cache names used to
// include CACHE_VERSION, and activation deletes caches whose names are not
// current, so every release since the tile caches were split per provider
// threw away every tile officers had downloaded for field work. Tile caches
// now carry their own TILE_CACHE_VERSION, frozen at v24, and tiles in any
// older tile cache are moved into the current ones instead of deleted.
// v26: Shapefile export works again. Choosing "Shapefile" in the Export GIS
// modal threw "offset is not defined" and produced no file: shp-write's
// dbf dependency assigns an undeclared variable, which is fine in the
// browserify bundle it was written for but a ReferenceError under ESM's
// strict mode. Two further breakages sat behind it — shp-write branches
// on `process.browser`, and delivers the zip by navigating to a data:
// URL, which Chromium blocks. src/shapefile-write.js now writes the
// .shp/.shx/.dbf/.prj/.cpg itself and hands over a Blob, so shp-write,
// dbf and JSZip are gone from the dependency tree.
// Also: the build no longer preloads the geotiff and shpjs chunks at
// startup. Vite's preload helper had been parked inside the geotiff
// chunk, and JSZip inside the shpjs one, so the entry needed both on
// every visit — about 428 kB that is now fetched only when a COG
// layer or a shapefile import actually asks for it. New hashed bundle.
// v27: The drawing rail's option bar covered the Transform button. ol-ext
// positions the flyout with `left: 100%`, which is meant to clear the
// whole rail — the rule that makes that work sets the rail's controls to
// `position: static`, and it only tied with ol-ext's own rule on
// specificity, so stylesheet order decided the winner and ol-ext's
// injected sheet came last. The flyout was therefore positioned against
// the Select button and landed on the second column. The selector now
// carries one class more. This is the same failure as v16, in a second
// place.
// Also: the local database now opens when the device is offline. The
// fetch handler skipped every request whose destination was 'worker', to
// stay clear of the Vite dev server's module plumbing. In a build,
// SQLocal's worker is a hashed file under /assets/, so it was never
// cached either — with the network down the request failed, initSchema()
// never resolved, and everything waiting on the database stopped without
// an error. The map, the tiles and the shell all worked, so the app
// looked like it was working offline while nothing could be saved or
// read. The skip now covers only the dev server's own paths.
// v28: The module is named GIS Tools, not Drawing Tools. The change reaches
// the installed app through manifest.json, so the shell has to be
// re-precached for a device that already has it on its home screen.
// v29: A direct request for /index.html bypassed index.php, so no session was
// injected and the app reported "No session injected" even for a
// signed-in officer. The LUSPA landing page links to exactly that URL.
// .htaccess now routes it through index.php; the shell still precaches it,
// since it is a real entry point and a navigation that misses the cache
// throws rather than falling back. Re-precache on installed devices.
const CACHE_VERSION = 'v29';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
@ -140,11 +194,24 @@ const API_CACHE = `api-${CACHE_VERSION}`;
// Limits are per-cache (not global). 5 000 tiles ≈ ~150 MB at ~30 KB/tile,
// which covers a Ghana district at zoom 10–15 (typical field-work range).
// ----------------------------------------------------------------------------
const TILES_OSM = `tiles-osm-${CACHE_VERSION}`;
const TILES_TOPO = `tiles-topo-${CACHE_VERSION}`;
const TILES_SATELLITE = `tiles-satellite-${CACHE_VERSION}`;
const TILES_CARTO_LIGHT = `tiles-carto-light-${CACHE_VERSION}`;
const TILES_CARTO_DARK = `tiles-carto-dark-${CACHE_VERSION}`;
//
// Tile caches are versioned separately from the app. They used to take their
// name from CACHE_VERSION, and the activate handler deletes every tiles-* cache
// whose name is not current — so each release silently threw away every tile an
// officer had downloaded for offline field work, for every provider. Map tiles
// do not change between releases of the app, so their cache names no longer do.
//
// TILE_CACHE_VERSION is frozen at the value CACHE_VERSION had when that stopped,
// so devices already on v24 keep their existing tile caches with nothing to copy.
// Change it only if the way tiles are stored changes — and even then, activate
// moves tiles from older tile caches into the new ones rather than deleting them
// (see migrateTileCache).
const TILE_CACHE_VERSION = 'v24';
const TILES_OSM = `tiles-osm-${TILE_CACHE_VERSION}`;
const TILES_TOPO = `tiles-topo-${TILE_CACHE_VERSION}`;
const TILES_SATELLITE = `tiles-satellite-${TILE_CACHE_VERSION}`;
const TILES_CARTO_LIGHT = `tiles-carto-light-${TILE_CACHE_VERSION}`;
const TILES_CARTO_DARK = `tiles-carto-dark-${TILE_CACHE_VERSION}`;
// Per-provider tile limits.
// • OSM and Topographic are the providers offered for active offline
@ -190,6 +257,13 @@ const AVG_TILE_BYTES = 30 * 1024;
// ----------------------------------------------------------------------------
// App shell assets — precached on install.
// ----------------------------------------------------------------------------
// Both '/' and '/index.html' are precached, because both are real entry
// points: the installed app opens start_url './' while the LUSPA landing page
// links to '/index.html'. Precaching both is only correct because .htaccess
// routes /index.html through index.php — before that rule, fetching it here
// stored a shell with no session injected. networkFirst() throws when a
// navigation misses the cache, so an un-precached entry point means a cold
// offline start at that URL fails outright.
const SHELL_ASSETS = [
'/',
'/index.html',
@ -221,36 +295,89 @@ self.addEventListener('install', (event) => {
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
// Build the set of caches that should remain
event.waitUntil((async () => {
const cacheNames = await caches.keys();
const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]);
return Promise.all(
// App caches from earlier releases hold nothing worth keeping: delete them.
await Promise.all(
cacheNames
// Delete anything that:
// • belongs to one of our managed cache prefixes (shell-, tiles-, modules-, api-)
// • but is NOT in the current keep set
// This includes the legacy "tiles-v1" single bucket.
.filter((name) => {
const isOurs =
name.startsWith('shell-') ||
name.startsWith('tiles-') ||
name.startsWith('modules-') ||
name.startsWith('api-');
return isOurs && !keep.has(name);
})
.filter((name) =>
(name.startsWith('shell-') || name.startsWith('modules-') || name.startsWith('api-'))
&& !keep.has(name))
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim())
);
// Take control before moving tiles, so the page is not held waiting on a
// copy that can run to thousands of entries.
await self.clients.claim();
// Tile caches from earlier releases hold what officers downloaded for
// offline use: move those tiles into the current caches, then delete. This
// includes the legacy single "tiles-v1" bucket, whose tiles are sorted into
// the right provider by URL.
const oldTileCaches = cacheNames.filter((name) => name.startsWith('tiles-') && !keep.has(name));
for (const name of oldTileCaches) {
await migrateTileCache(name);
}
})());
});
/**
* Move the tiles in a tile cache from an earlier release into the current
* per-provider caches, then delete it.
*
* Each tile goes where getTileCacheName() would put it today, so the legacy
* single bucket is sorted by provider too. Nothing already in the current cache
* is overwritten, and no cache is filled past its limit. If the copy fails part
* way, the old cache is left in place rather than deleted, so its tiles are not
* lost and the next activation carries on from there.
*/
async function migrateTileCache(oldName) {
try {
const oldCache = await caches.open(oldName);
const requests = await oldCache.keys();
const targets = new Map(); // cacheName -> { cache, count }
let moved = 0;
let skipped = 0;
for (const request of requests) {
const url = new URL(request.url);
const targetName = getTileCacheName(url);
// A provider no longer cached, or a Carto tile requested without a key.
// The app has sent the CARTO key on every tile request since August 2026,
// so a keyless Carto URL is never requested again — and anything cached
// after CARTO began enforcing keys carries its "API KEY REQUIRED" stamp.
const deadCarto = url.hostname.endsWith('basemaps.cartocdn.com') && !url.searchParams.has('key');
if (!targetName || deadCarto) { skipped++; continue; }
let target = targets.get(targetName);
if (!target) {
const cache = await caches.open(targetName);
target = { cache, count: (await cache.keys()).length };
targets.set(targetName, target);
}
if (target.count >= (TILE_LIMITS[targetName] || 1500)) { skipped++; continue; }
if (await target.cache.match(request)) { skipped++; continue; }
const response = await oldCache.match(request);
if (!response) { skipped++; continue; }
await target.cache.put(request, response);
target.count++;
moved++;
}
await caches.delete(oldName);
console.log(`[SW] Moved ${moved} tiles out of ${oldName} (${skipped} not needed) and removed it`);
} catch (err) {
console.warn(`[SW] Could not move tiles out of ${oldName}; kept it for the next activation:`, err);
}
}
// ============================================================================
// FETCH EVENT
// ============================================================================
@ -265,11 +392,17 @@ self.addEventListener('fetch', (event) => {
// Skip chrome-extension and other non-http(s) requests
if (!url.protocol.startsWith('http')) return;
// Skip worker files and Vite dev-server node_modules requests —
// intercepting these breaks module workers (e.g. SQLocal/SQLite).
if (url.pathname.includes('node_modules') ||
url.search.includes('worker_file') ||
request.destination === 'worker') return;
// Skip the Vite dev server's own module plumbing — it serves worker scripts
// straight out of node_modules with a ?worker_file query, and intercepting
// those breaks module workers (e.g. SQLocal/SQLite).
if (url.pathname.includes('node_modules') || url.search.includes('worker_file')) return;
// A worker script in a BUILT app is a hashed file under /assets/, as
// immutable as any other asset — and it HAS to be cached, or SQLocal cannot
// start its worker with the network down and the local database never opens.
// Skipping every 'worker' destination was what made that happen: see v27.
// Anything else with destination 'worker' is still left alone.
if (request.destination === 'worker' && !isAppAsset(url)) return;
// ----- TILE REQUESTS — passive cache-then-network (per-host bucket) -----
const tileCache = getTileCacheName(url);

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#005eb8">
<meta name="description" content="LUPMIS2 Drawing Tools">
<meta name="description" content="LUPMIS2 GIS Tools">
<!-- PWA Manifest -->
<link rel="manifest" href="manifest.json">
@ -61,7 +61,7 @@
}
</style>
<title>LUPMIS2 Drawing Tools</title>
<title>LUPMIS2 GIS Tools</title>
<style>
/* LUSPA Design System Tokens
@ -1443,6 +1443,86 @@
border-top-color: var(--primary);
}
/* ----------------------------------------------------------------
Radius adjuster on a finished circle's tooltip.
The label becomes a control: minus, the figure (tap to type), plus.
Targets are 32px so they can be hit with a thumb; the tooltip is
frozen where the draw finished so the buttons do not slide away as
the radius changes.
---------------------------------------------------------------- */
.measure-tooltip-adjust {
padding: 5px 6px;
cursor: default;
/* The overlay is created with stopEvent:false so the map keeps working
through the label. The buttons need the events, so re-enable them
here and let the JS stop them reaching the map. */
pointer-events: auto;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.measure-tooltip-adjust .mt-row {
display: flex;
align-items: center;
gap: 4px;
}
.measure-tooltip-adjust .mt-step,
.measure-tooltip-adjust .mt-value {
font-family: var(--font-body);
font-weight: 600;
color: inherit;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 6px;
cursor: pointer;
padding: 0;
}
.measure-tooltip-adjust .mt-step {
width: 32px;
height: 32px;
font-size: 18px;
line-height: 1;
flex: none;
}
.measure-tooltip-adjust .mt-value {
min-width: 92px;
height: 32px;
padding: 0 10px;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.measure-tooltip-adjust .mt-step:hover,
.measure-tooltip-adjust .mt-value:hover {
background: rgba(255, 255, 255, 0.3);
}
.measure-tooltip-adjust .mt-step:active {
transform: scale(0.94);
}
.measure-tooltip-adjust .mt-step:focus-visible,
.measure-tooltip-adjust .mt-value:focus-visible {
outline: 2px solid #fff;
outline-offset: 1px;
}
.measure-tooltip-adjust .mt-input {
width: 92px;
height: 32px;
padding: 0 8px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--foreground);
background: #fff;
border: 2px solid var(--primary);
border-radius: 6px;
}
.measure-tooltip-adjust .mt-area {
font-size: 11px;
font-weight: 500;
opacity: 0.85;
text-align: center;
margin-top: 3px;
}
/* Arrow pointing to measurement point */
.measure-tooltip::before {
content: '';
@ -1735,8 +1815,15 @@
block to the rail itself, so `left: 100%` clears the whole panel. The
flyout then always appears at the same place (rail's right edge, centred)
instead of jumping to whichever button was pressed.
The selector needs three classes plus `.ol-control` to beat ol-ext's own
`.ol-control.ol-bar .ol-control { position: relative }`. With one class
fewer the two rules tie on specificity, source order decides, and
ol-ext's stylesheet is injected after this block — so the controls stayed
`relative`, the flyout was positioned against the Select button instead
of the rail, and it covered the Transform button in the second column.
---------------------------------------------------------------- */
.ol-editbar.ol-editbar-rail > .ol-control {
.ol-control.ol-bar.ol-editbar.ol-editbar-rail > .ol-control {
position: static;
}
.ol-editbar-rail .ol-option-bar {
@ -1924,7 +2011,7 @@
<nav class="navbar py-2">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">
<span class="me-2">🌍</span><span class="brand-text">LUPMIS2 Drawing Tools</span>
<span class="me-2">🌍</span><span class="brand-text">LUPMIS2 GIS Tools</span>
</span>
<!-- Live GPS status: lon/lat, accuracy (precision) and satellites.

11
main.js
View File

@ -218,10 +218,19 @@ async function initApp() {
if (showNoDistrictBlockerIfNeeded()) return;
// 1. Initialize PWA features (Service Worker, install prompt, offline detection)
//
// The service worker is registered in built applications only. On the Vite
// dev server it cannot do its job anyway — the modules it would need are
// served out of node_modules, which it deliberately keeps clear of — while
// it does cache main.js and src/*.js cache-first, so it can serve a stale
// module and fight hot reloading. Offline behaviour is tested against a
// build (npm run build && npm run preview), never against the dev server.
// When registration is off, initPWA also removes any worker an earlier
// session installed on this origin.
await initPWA({
installButton: '#install-btn',
offlineIndicator: '#offline-indicator',
autoRegisterSW: true
autoRegisterSW: import.meta.env.PROD,
});
// 2. Initialize the map

41
package-lock.json generated
View File

@ -23,7 +23,6 @@
"jspdf-autotable": "^5.0.7",
"ol": "^10.3.0",
"ol-ext": "^4.0.24",
"shp-write": "^0.3.2",
"shpjs": "^6.2.0",
"sqlocal": "^0.16.0"
},
@ -1176,15 +1175,6 @@
"utrie": "^1.0.2"
}
},
"node_modules/dbf": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/dbf/-/dbf-0.1.4.tgz",
"integrity": "sha512-7tQ8w5NB74PL1f0Z/NQ6Y+URjBFhtEsFxzEQSzot2+VpLwWfrNnxFVhzWm6dJyEtFq0WkYWcGEMDf39fy8JFaw==",
"license": "BSD-2-Clause",
"dependencies": {
"jdataview": "~2.5.0"
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
@ -1337,11 +1327,6 @@
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/jdataview": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/jdataview/-/jdataview-2.5.0.tgz",
"integrity": "sha512-ZJop3D5nyDcWPBPv4NPnhCvx3HgQNsCXMfw8gpNKY16BobgxmVF+kJ08aHuqk6bJQVeL2mkf6nDCcZPMompalw=="
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
@ -1368,21 +1353,6 @@
"jspdf": "^2 || ^3 || ^4"
}
},
"node_modules/jszip": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-2.5.0.tgz",
"integrity": "sha512-IRoyf8JSYY3nx+uyh5xPc0qdy8pUDTp2UkHOWYNF/IO/3D8nx7899UlSAjD8rf8wUgOmm0lACWx/GbW3EaxIXQ==",
"license": "MIT or GPLv3",
"dependencies": {
"pako": "~0.2.5"
}
},
"node_modules/jszip/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/lerc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
@ -1684,17 +1654,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/shp-write": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/shp-write/-/shp-write-0.3.2.tgz",
"integrity": "sha512-RNmfm+qzIwgwGMiV21lCxfEAtgP/owAd+sHLr6Qu+aDR1bbrCZ42H89nA9FQWUqfL+WHJy3n8+cTZxJrL/ZKWA==",
"deprecated": "This package has moved under the @mapbox organization, and can be found here: https://www.npmjs.com/package/@mapbox/shp-write",
"license": "BSD-2-Clause",
"dependencies": {
"dbf": "0.1.4",
"jszip": "2.5.0"
}
},
"node_modules/shpjs": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/shpjs/-/shpjs-6.2.0.tgz",

View File

@ -24,7 +24,6 @@
"jspdf-autotable": "^5.0.7",
"ol": "^10.3.0",
"ol-ext": "^4.0.24",
"shp-write": "^0.3.2",
"shpjs": "^6.2.0",
"sqlocal": "^0.16.0"
},

View File

@ -70,6 +70,14 @@ DirectoryIndex index.php index.html
# index.php. Query strings (?mode=permit&...) pass through automatically.
RewriteRule ^embed/?$ embed.php [L]
# A direct request for /index.html must still go through index.php, which
# is what injects window.LUPMIS_SESSION. Without this rule the SPA fallback
# below cannot help: its RewriteCond !-f fails precisely because index.html
# IS a real file, so Apache serves it raw and the app starts with no
# session — the "No session injected" warning. index.php reads index.html
# from the filesystem (__DIR__), not over HTTP, so there is no loop.
RewriteRule ^index\.html$ index.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.

View File

@ -35,7 +35,17 @@ header_remove('X-Frame-Options');
// ────────────────────────────────────────────────────────────────────────────
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);
// The SSO service is reached by its container name on the internal Docker
// network. The public hostname must NOT be used from inside the server:
// the Lands Commission's NAT rule blocks traffic that leaves the server and
// tries to come back in, so a call to https://lupmis4luspa.org/ from here
// hangs until CURLOPT_TIMEOUT and then fails validation, bouncing the user
// back to SSO. Measured from inside the spdrawing container on 2026-10-12:
// http://l4lmainbackend/sso/validate answers in 25 ms with no redirect,
// while the public name times out. Override with LUPMIS_SSO_BASE if the
// container is renamed.
$ssoBase = getenv('LUPMIS_SSO_BASE') ?: 'http://l4lmainbackend';
$validate_url = rtrim($ssoBase, '/') . '/sso/validate?token=' . urlencode($plainToken);
$curl = curl_init();
curl_setopt_array($curl, [

View File

@ -69,7 +69,17 @@ if (isset($_GET['logout'])) {
// cleared the SSO cookie, which left the stale district pinned).
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);
// The SSO service is reached by its container name on the internal Docker
// network. The public hostname must NOT be used from inside the server:
// the Lands Commission's NAT rule blocks traffic that leaves the server and
// tries to come back in, so a call to https://lupmis4luspa.org/ from here
// hangs until CURLOPT_TIMEOUT and then fails validation, bouncing the user
// back to SSO. Measured from inside the spdrawing container on 2026-10-12:
// http://l4lmainbackend/sso/validate answers in 25 ms with no redirect,
// while the public name times out. Override with LUPMIS_SSO_BASE if the
// container is renamed.
$ssoBase = getenv('LUPMIS_SSO_BASE') ?: 'http://l4lmainbackend';
$validate_url = rtrim($ssoBase, '/') . '/sso/validate?token=' . urlencode($plainToken);
$curl = curl_init();
curl_setopt_array($curl, [

View File

@ -1,5 +1,5 @@
{
"name": "LUPMIS2 Drawing Tools",
"name": "LUPMIS2 GIS Tools",
"short_name": "LUPMIS2",
"description": "Map and GIS functions for Land Use Planning in Ghana",
"start_url": "./",

View File

@ -130,7 +130,61 @@
// not only in the moment a location is saved. Previously a reload put the
// layer back to hidden and every marker vanished. An empty district still
// opens with the layer off.
const CACHE_VERSION = 'v23';
// v24: Circle measurements can be resized after drawing: the tooltip on a
// finished circle carries minus, the figure (tap to type an exact radius)
// and plus, stepping by increments that grow with the size. Circle radius
// and area are now reported as ground distance rather than projected Web
// Mercator metres, which overstated them by 1/cos(latitude) — about 3 m on
// a 200 m circle at Wa. The Circle and Area analysis popups can be dragged
// by their title bar, staying attached to the shape they describe.
// v25: Releases no longer delete offline map tiles. Tile cache names used to
// include CACHE_VERSION, and activation deletes caches whose names are not
// current, so every release since the tile caches were split per provider
// threw away every tile officers had downloaded for field work. Tile caches
// now carry their own TILE_CACHE_VERSION, frozen at v24, and tiles in any
// older tile cache are moved into the current ones instead of deleted.
// v26: Shapefile export works again. Choosing "Shapefile" in the Export GIS
// modal threw "offset is not defined" and produced no file: shp-write's
// dbf dependency assigns an undeclared variable, which is fine in the
// browserify bundle it was written for but a ReferenceError under ESM's
// strict mode. Two further breakages sat behind it — shp-write branches
// on `process.browser`, and delivers the zip by navigating to a data:
// URL, which Chromium blocks. src/shapefile-write.js now writes the
// .shp/.shx/.dbf/.prj/.cpg itself and hands over a Blob, so shp-write,
// dbf and JSZip are gone from the dependency tree.
// Also: the build no longer preloads the geotiff and shpjs chunks at
// startup. Vite's preload helper had been parked inside the geotiff
// chunk, and JSZip inside the shpjs one, so the entry needed both on
// every visit — about 428 kB that is now fetched only when a COG
// layer or a shapefile import actually asks for it. New hashed bundle.
// v27: The drawing rail's option bar covered the Transform button. ol-ext
// positions the flyout with `left: 100%`, which is meant to clear the
// whole rail — the rule that makes that work sets the rail's controls to
// `position: static`, and it only tied with ol-ext's own rule on
// specificity, so stylesheet order decided the winner and ol-ext's
// injected sheet came last. The flyout was therefore positioned against
// the Select button and landed on the second column. The selector now
// carries one class more. This is the same failure as v16, in a second
// place.
// Also: the local database now opens when the device is offline. The
// fetch handler skipped every request whose destination was 'worker', to
// stay clear of the Vite dev server's module plumbing. In a build,
// SQLocal's worker is a hashed file under /assets/, so it was never
// cached either — with the network down the request failed, initSchema()
// never resolved, and everything waiting on the database stopped without
// an error. The map, the tiles and the shell all worked, so the app
// looked like it was working offline while nothing could be saved or
// read. The skip now covers only the dev server's own paths.
// v28: The module is named GIS Tools, not Drawing Tools. The change reaches
// the installed app through manifest.json, so the shell has to be
// re-precached for a device that already has it on its home screen.
// v29: A direct request for /index.html bypassed index.php, so no session was
// injected and the app reported "No session injected" even for a
// signed-in officer. The LUSPA landing page links to exactly that URL.
// .htaccess now routes it through index.php; the shell still precaches it,
// since it is a real entry point and a navigation that misses the cache
// throws rather than falling back. Re-precache on installed devices.
const CACHE_VERSION = 'v29';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
@ -140,11 +194,24 @@ const API_CACHE = `api-${CACHE_VERSION}`;
// Limits are per-cache (not global). 5 000 tiles ≈ ~150 MB at ~30 KB/tile,
// which covers a Ghana district at zoom 10–15 (typical field-work range).
// ----------------------------------------------------------------------------
const TILES_OSM = `tiles-osm-${CACHE_VERSION}`;
const TILES_TOPO = `tiles-topo-${CACHE_VERSION}`;
const TILES_SATELLITE = `tiles-satellite-${CACHE_VERSION}`;
const TILES_CARTO_LIGHT = `tiles-carto-light-${CACHE_VERSION}`;
const TILES_CARTO_DARK = `tiles-carto-dark-${CACHE_VERSION}`;
//
// Tile caches are versioned separately from the app. They used to take their
// name from CACHE_VERSION, and the activate handler deletes every tiles-* cache
// whose name is not current — so each release silently threw away every tile an
// officer had downloaded for offline field work, for every provider. Map tiles
// do not change between releases of the app, so their cache names no longer do.
//
// TILE_CACHE_VERSION is frozen at the value CACHE_VERSION had when that stopped,
// so devices already on v24 keep their existing tile caches with nothing to copy.
// Change it only if the way tiles are stored changes — and even then, activate
// moves tiles from older tile caches into the new ones rather than deleting them
// (see migrateTileCache).
const TILE_CACHE_VERSION = 'v24';
const TILES_OSM = `tiles-osm-${TILE_CACHE_VERSION}`;
const TILES_TOPO = `tiles-topo-${TILE_CACHE_VERSION}`;
const TILES_SATELLITE = `tiles-satellite-${TILE_CACHE_VERSION}`;
const TILES_CARTO_LIGHT = `tiles-carto-light-${TILE_CACHE_VERSION}`;
const TILES_CARTO_DARK = `tiles-carto-dark-${TILE_CACHE_VERSION}`;
// Per-provider tile limits.
// • OSM and Topographic are the providers offered for active offline
@ -190,6 +257,13 @@ const AVG_TILE_BYTES = 30 * 1024;
// ----------------------------------------------------------------------------
// App shell assets — precached on install.
// ----------------------------------------------------------------------------
// Both '/' and '/index.html' are precached, because both are real entry
// points: the installed app opens start_url './' while the LUSPA landing page
// links to '/index.html'. Precaching both is only correct because .htaccess
// routes /index.html through index.php — before that rule, fetching it here
// stored a shell with no session injected. networkFirst() throws when a
// navigation misses the cache, so an un-precached entry point means a cold
// offline start at that URL fails outright.
const SHELL_ASSETS = [
'/',
'/index.html',
@ -221,36 +295,89 @@ self.addEventListener('install', (event) => {
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
// Build the set of caches that should remain
event.waitUntil((async () => {
const cacheNames = await caches.keys();
const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]);
return Promise.all(
// App caches from earlier releases hold nothing worth keeping: delete them.
await Promise.all(
cacheNames
// Delete anything that:
// • belongs to one of our managed cache prefixes (shell-, tiles-, modules-, api-)
// • but is NOT in the current keep set
// This includes the legacy "tiles-v1" single bucket.
.filter((name) => {
const isOurs =
name.startsWith('shell-') ||
name.startsWith('tiles-') ||
name.startsWith('modules-') ||
name.startsWith('api-');
return isOurs && !keep.has(name);
})
.filter((name) =>
(name.startsWith('shell-') || name.startsWith('modules-') || name.startsWith('api-'))
&& !keep.has(name))
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim())
);
// Take control before moving tiles, so the page is not held waiting on a
// copy that can run to thousands of entries.
await self.clients.claim();
// Tile caches from earlier releases hold what officers downloaded for
// offline use: move those tiles into the current caches, then delete. This
// includes the legacy single "tiles-v1" bucket, whose tiles are sorted into
// the right provider by URL.
const oldTileCaches = cacheNames.filter((name) => name.startsWith('tiles-') && !keep.has(name));
for (const name of oldTileCaches) {
await migrateTileCache(name);
}
})());
});
/**
* Move the tiles in a tile cache from an earlier release into the current
* per-provider caches, then delete it.
*
* Each tile goes where getTileCacheName() would put it today, so the legacy
* single bucket is sorted by provider too. Nothing already in the current cache
* is overwritten, and no cache is filled past its limit. If the copy fails part
* way, the old cache is left in place rather than deleted, so its tiles are not
* lost and the next activation carries on from there.
*/
async function migrateTileCache(oldName) {
try {
const oldCache = await caches.open(oldName);
const requests = await oldCache.keys();
const targets = new Map(); // cacheName -> { cache, count }
let moved = 0;
let skipped = 0;
for (const request of requests) {
const url = new URL(request.url);
const targetName = getTileCacheName(url);
// A provider no longer cached, or a Carto tile requested without a key.
// The app has sent the CARTO key on every tile request since August 2026,
// so a keyless Carto URL is never requested again — and anything cached
// after CARTO began enforcing keys carries its "API KEY REQUIRED" stamp.
const deadCarto = url.hostname.endsWith('basemaps.cartocdn.com') && !url.searchParams.has('key');
if (!targetName || deadCarto) { skipped++; continue; }
let target = targets.get(targetName);
if (!target) {
const cache = await caches.open(targetName);
target = { cache, count: (await cache.keys()).length };
targets.set(targetName, target);
}
if (target.count >= (TILE_LIMITS[targetName] || 1500)) { skipped++; continue; }
if (await target.cache.match(request)) { skipped++; continue; }
const response = await oldCache.match(request);
if (!response) { skipped++; continue; }
await target.cache.put(request, response);
target.count++;
moved++;
}
await caches.delete(oldName);
console.log(`[SW] Moved ${moved} tiles out of ${oldName} (${skipped} not needed) and removed it`);
} catch (err) {
console.warn(`[SW] Could not move tiles out of ${oldName}; kept it for the next activation:`, err);
}
}
// ============================================================================
// FETCH EVENT
// ============================================================================
@ -265,11 +392,17 @@ self.addEventListener('fetch', (event) => {
// Skip chrome-extension and other non-http(s) requests
if (!url.protocol.startsWith('http')) return;
// Skip worker files and Vite dev-server node_modules requests —
// intercepting these breaks module workers (e.g. SQLocal/SQLite).
if (url.pathname.includes('node_modules') ||
url.search.includes('worker_file') ||
request.destination === 'worker') return;
// Skip the Vite dev server's own module plumbing — it serves worker scripts
// straight out of node_modules with a ?worker_file query, and intercepting
// those breaks module workers (e.g. SQLocal/SQLite).
if (url.pathname.includes('node_modules') || url.search.includes('worker_file')) return;
// A worker script in a BUILT app is a hashed file under /assets/, as
// immutable as any other asset — and it HAS to be cached, or SQLocal cannot
// start its worker with the network down and the local database never opens.
// Skipping every 'worker' destination was what made that happen: see v27.
// Anything else with destination 'worker' is still left alone.
if (request.destination === 'worker' && !isAppAsset(url)) return;
// ----- TILE REQUESTS — passive cache-then-network (per-host bucket) -----
const tileCache = getTileCacheName(url);

View File

@ -2,14 +2,15 @@
* xlsx.js — a minimal, dependency-free XLSX writer.
*
* An .xlsx file is a ZIP archive of XML parts. This module builds the few
* parts Excel actually requires and packs them with a small STORED (uncompressed)
* ZIP writer — readers accept stored entries, and an analysis table is far too
* small for compression to matter.
* parts Excel actually requires and packs them with ../zip-store.js, a small
* STORED (uncompressed) ZIP writer — readers accept stored entries, and an
* analysis table is far too small for compression to matter.
*
* Why not a library: SheetJS's npm package is stale and carries advisories,
* ExcelJS is heavy for an offline-first field app, and the JSZip in node_modules
* is only a transitive dependency of shp-write (so it could disappear). This is
* ~200 lines we control, adds nothing to the dependency tree, and works offline.
* Why not a library: SheetJS's npm package is stale and carries advisories, and
* ExcelJS is heavy for an offline-first field app. This is ~200 lines we
* control, adds nothing to the dependency tree, and works offline. (The JSZip
* that used to sit in node_modules is gone: it came in only via shp-write,
* which ../shapefile-write.js replaced.)
*
* buildXlsxBlob([{ name: 'Results', rows: [['Zone','Count'], ['North', 3]] }])
*
@ -18,6 +19,8 @@
* as { cells: [...], bold: true } renders that row in bold.
*/
import { zipStore, downloadBlob } from '../zip-store.js';
// ---------------------------------------------------------------------------
// XML helpers
// ---------------------------------------------------------------------------
@ -78,79 +81,6 @@ const STYLES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
</styleSheet>`;
// ---------------------------------------------------------------------------
// Minimal STORED-ZIP writer
// ---------------------------------------------------------------------------
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
/** Pack files (name → string) into an uncompressed ZIP Blob. */
function zipStore(files) {
const enc = new TextEncoder();
const entries = Object.entries(files).map(([name, text]) => ({
name, nameBytes: enc.encode(name), data: enc.encode(text),
}));
// DOS time/date — a fixed timestamp keeps output deterministic.
const time = 0, date = ((2020 - 1980) << 9) | (1 << 5) | 1;
const chunks = [];
const central = [];
let offset = 0;
const u16 = (v) => [v & 0xFF, (v >>> 8) & 0xFF];
const u32 = (v) => [v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF];
for (const e of entries) {
const crc = crc32(e.data);
const size = e.data.length;
const local = [
...u32(0x04034b50), ...u16(20), ...u16(0), ...u16(0),
...u16(time), ...u16(date), ...u32(crc), ...u32(size), ...u32(size),
...u16(e.nameBytes.length), ...u16(0),
];
chunks.push(new Uint8Array(local), e.nameBytes, e.data);
central.push([
...u32(0x02014b50), ...u16(20), ...u16(20), ...u16(0), ...u16(0),
...u16(time), ...u16(date), ...u32(crc), ...u32(size), ...u32(size),
...u16(e.nameBytes.length), ...u16(0), ...u16(0), ...u16(0), ...u16(0),
...u32(0), ...u32(offset),
]);
central.push(e.nameBytes);
offset += local.length + e.nameBytes.length + size;
}
const cdParts = [];
let cdSize = 0;
for (const part of central) {
const arr = part instanceof Uint8Array ? part : new Uint8Array(part);
cdParts.push(arr); cdSize += arr.length;
}
const end = new Uint8Array([
...u32(0x06054b50), ...u16(0), ...u16(0),
...u16(entries.length), ...u16(entries.length),
...u32(cdSize), ...u32(offset), ...u16(0),
]);
return new Blob([...chunks, ...cdParts, end],
{ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@ -204,16 +134,8 @@ ${sheetEntries.map((s) => `<Relationship Id="rId${s.id}" Type="http://schemas.op
files['xl/styles.xml'] = STYLES_XML;
return zipStore(files);
}
/** Trigger a browser download of a Blob. */
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
return zipStore(files,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
}
/** Convenience: build and download in one call. */

View File

@ -19,6 +19,7 @@ import { getLength, getArea } from 'ol/sphere';
import Feature from 'ol/Feature';
import { unByKey } from 'ol/Observable';
import { formatLength, formatArea, formatCircleExtent } from '../units.js';
import { groundFromProjected, projectedFromGround, radiusStep } from '../units.js';
// ol-ext imports
import EditBar from 'ol-ext/control/EditBar';
@ -143,6 +144,164 @@ export class MapTools {
this.map.addOverlay(this.measureTooltip);
}
/**
* Set a measured circle's radius from a GROUND distance in metres, and bring
* everything that reports it back into agreement.
*
* This is the only place a radius is written. Measurements were made
* read-only in an earlier change precisely because a draggable handle moved
* the geometry while the popup went on reporting the radius captured at draw
* time — the figures and the shape disagreed. Anything that adjusts a circle
* goes through here so that cannot happen again.
*
* @param {ol/Feature} feature the measure_circle feature
* @param {number} groundMetres what the officer means on the ground
*/
setCircleRadius(feature, groundMetres) {
const geom = feature.getGeometry();
if (!geom || typeof geom.setRadius !== 'function') return;
const center = geom.getCenter();
// A circle with no size cannot be analysed and cannot be dragged back, so
// hold it just above zero rather than letting it collapse.
const ground = Math.max(1, Number(groundMetres) || 0);
const projected = projectedFromGround(ground, center);
geom.setRadius(projected);
feature.set('_radius', projected);
const line = feature.get('_radiusLine');
if (line) {
line.setGeometry(new LineString([center, [center[0] + projected, center[1]]]));
}
this._refreshRadiusAdjuster(feature);
// Deliberately no measure-complete event here. Consumers treat that as "a
// new measurement was drawn", and firing it on every stepper tap would be a
// stream of false completions. The Circle Analysis popup reads the live
// geometry, so it is already correct next time it opens.
}
/** Ground radius of a measured circle, in metres. */
getCircleGroundRadius(feature) {
const geom = feature.getGeometry();
// Rounded to the micrometre. OpenLayers stores a circle as its centre and a
// point on the edge, and getRadius() recomputes (cx + r) - cx — which, at map
// coordinates in the hundreds of thousands, comes back a few 1e-11 short. A
// typed 200 then reads as 199.99999999997, falls below the 200 threshold in
// radiusStep(), and steps by 5 instead of 10: exactly at the round numbers
// officers are most likely to enter.
return Math.round(groundFromProjected(geom.getRadius(), geom.getCenter()) * 1e6) / 1e6;
}
/**
* Turn a finished circle's tooltip into a radius adjuster: minus, the figure
* itself (tap to type), plus.
*
* A rotary dial was considered and rejected — a radius runs from a few metres
* to several kilometres, and no dial gives usable resolution across that range
* on a tooltip this size. Stepping snaps to a round increment that grows with
* the value, so one tap means something at 20 m and at 2 km.
*
* @private
*/
_attachRadiusAdjuster(feature, overlay, element) {
if (!element) return;
// The tooltip is anchored to the circumference (geom.getLastCoordinate()),
// so re-positioning it on every change would slide the buttons out from
// under the officer's finger. Freeze it where the draw finished instead.
overlay.setPosition(overlay.getPosition());
element.classList.add('measure-tooltip-adjust');
element.innerHTML = `
<div class="mt-row">
<button type="button" class="mt-step" data-dir="-1" aria-label="Smaller radius">&minus;</button>
<button type="button" class="mt-value" aria-label="Type an exact radius"></button>
<button type="button" class="mt-step" data-dir="1" aria-label="Larger radius">+</button>
</div>
<div class="mt-area"></div>`;
// The overlay is created with stopEvent:false so the map still receives
// pointer events through it. That is right for a label and wrong for a
// control, so the buttons stop their own events reaching the map — without
// it, a tap here also pans or starts the next measurement.
for (const type of ['pointerdown', 'pointerup', 'click', 'dblclick', 'touchstart']) {
element.addEventListener(type, (e) => e.stopPropagation());
}
element.querySelectorAll('.mt-step').forEach((btn) => {
btn.addEventListener('click', () => {
const current = this.getCircleGroundRadius(feature);
const step = radiusStep(current);
const dir = Number(btn.dataset.dir);
// Snap to the step so repeated taps land on round numbers rather than
// carrying the fractional radius the drag happened to end on.
const snapped = Math.round(current / step) * step;
const next = Math.abs(snapped - current) > 0.01 && Math.sign(snapped - current) === dir
? snapped
: snapped + dir * step;
this.setCircleRadius(feature, next);
});
});
element.querySelector('.mt-value').addEventListener('click', () => {
this._promptRadius(feature, element);
});
feature.set('_adjustEl', element);
this._refreshRadiusAdjuster(feature);
}
/** Swap the figure for an input so an exact radius can be typed. @private */
_promptRadius(feature, element) {
const valueBtn = element.querySelector('.mt-value');
if (!valueBtn || element.querySelector('.mt-input')) return;
const current = this.getCircleGroundRadius(feature);
const input = document.createElement('input');
input.type = 'number';
input.className = 'mt-input';
input.inputMode = 'decimal'; // a numeric keypad on a phone, not a full keyboard
input.min = '1';
input.step = 'any';
input.value = String(Math.round(current * 100) / 100);
input.setAttribute('aria-label', 'Radius in metres');
valueBtn.replaceWith(input);
input.focus();
input.select();
// Settles exactly once. Swapping the input out of the DOM fires `blur`
// synchronously while it is still connected, and the blur handler commits —
// so without this guard, Escape would discard the value and then the blur it
// triggers would apply it anyway.
let settled = false;
const commit = (apply) => {
if (settled || !input.isConnected) return;
settled = true;
if (apply) this.setCircleRadius(feature, parseFloat(input.value));
input.replaceWith(valueBtn);
this._refreshRadiusAdjuster(feature);
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') commit(true);
if (e.key === 'Escape') commit(false);
});
input.addEventListener('blur', () => commit(true));
}
/** Redraw the adjuster's figures from the geometry. @private */
_refreshRadiusAdjuster(feature) {
const element = feature.get('_adjustEl');
if (!element) return;
const ground = this.getCircleGroundRadius(feature);
const valueBtn = element.querySelector('.mt-value');
if (valueBtn) valueBtn.textContent = formatLength(ground);
const areaEl = element.querySelector('.mt-area');
if (areaEl) areaEl.textContent = formatArea(Math.PI * ground * ground);
}
/**
* Remove any active interaction
*/
@ -205,7 +364,9 @@ export class MapTools {
const geom = e.target;
if (geom instanceof Circle) {
const radius = geom.getRadius();
// Ground distance, not the projected radius the geometry stores —
// see the Web Mercator note in units.js.
const radius = groundFromProjected(geom.getRadius(), geom.getCenter());
const area = formatCircleExtent(radius);
const radiusFormatted = formatLength(radius);
@ -240,10 +401,13 @@ export class MapTools {
});
radiusLine.set('_layerType', 'measure_circle_radius');
this.measureSource.addFeature(radiusLine);
// The line belongs to this circle, so setRadius can find and redraw it.
feature.set('_radiusLine', radiusLine);
// Make tooltip static
// Make tooltip static, and turn it into the radius adjuster.
this.measureTooltipElement.className = 'measure-tooltip measure-tooltip-static';
this.measureTooltip.setOffset([0, -7]);
this._attachRadiusAdjuster(feature, this.measureTooltip, this.measureTooltipElement);
// Create new tooltip for next measurement
this.measureTooltipElement = null;

View File

@ -42,6 +42,7 @@ import { getArea, getLength } from 'ol/sphere';
import { fromCircle } from 'ol/geom/Polygon';
import ScaleLine from 'ol/control/ScaleLine';
import { formatLength, formatLengthFull, formatArea, formatAreaFull } from '../units.js';
import { groundFromProjected } from '../units.js';
// ol-ext LayerSwitcher
import LayerSwitcher from 'ol-ext/control/LayerSwitcher';
@ -104,6 +105,35 @@ import '../styles/layerswitcher.css';
* and every button is given an icon from here instead, which makes this the single
* place the toolbar's iconography is defined.
*/
/**
* CARTO basemap key, used by the Carto Light and Carto Dark base maps.
*
* Since late August 2026 CARTO stamps an "API KEY REQUIRED" watermark into
* every tile requested without a key. The request still returns HTTP 200, so a
* missing or mistyped key never shows up as an error — only as the watermark on
* the map. The parameter is `key`; `api_key` is silently ignored and produces
* the same watermarked tiles.
*
* Supplied through VITE_CARTO_API_KEY in .env.local (git-ignored, see
* .env.example), so rotating it is a configuration change. It is inlined into
* the built bundle and sent in every tile URL, so it is visible to anyone using
* the application — normal for a tile key, which is why it is kept separate from
* credentials that must stay private.
*/
const CARTO_API_KEY = import.meta.env.VITE_CARTO_API_KEY || '';
/** Tile URL template for a CARTO raster style, with the key when there is one. */
function cartoTileUrl(style) {
const url = `https://{a-c}.basemaps.cartocdn.com/${style}/{z}/{x}/{y}.png`;
return CARTO_API_KEY ? `${url}?key=${encodeURIComponent(CARTO_API_KEY)}` : url;
}
/**
* Resting offset of the info / analysis popup above its anchor. Dragging the
* popup by its title bar moves it from here; reopening returns it here.
*/
const POPUP_BASE_OFFSET = [0, -10];
const TOOLBAR_ICONS = {
// ol-ext built-ins
'ol-selection': 'bi-cursor',
@ -1670,7 +1700,7 @@ export class MapView {
this.infoPopup = new Overlay({
element: this.infoPopupElement,
positioning: 'bottom-center',
offset: [0, -10],
offset: POPUP_BASE_OFFSET.slice(),
stopEvent: true,
autoPan: true,
autoPanAnimation: { duration: 250 },
@ -1821,6 +1851,61 @@ export class MapView {
* @param {Array<{label:string, value:string, color?:string, empty?:boolean}>} dataRows
* @returns {string} HTML
*/
/**
* Let the analysis popup be dragged by its title bar.
*
* The popup covers whatever it is reporting on — a circle's own parcels sit
* directly under it — so it has to be movable to be read against the map.
*
* The drag changes the overlay's OFFSET, not its position. The popup stays
* tied to the shape it describes, so panning or zooming carries it along and
* it keeps pointing at the right circle; only its resting place relative to
* that anchor changes. Setting a new position instead would cut it loose and
* re-anchor it to whatever ground it was dropped on.
*
* @private
*/
_makePopupDraggable() {
const header = this.infoPopupElement.querySelector('#info-popup-header');
if (!header) return;
// Each popup opens where it belongs; a drag applies to that showing only.
this.infoPopup.setOffset(POPUP_BASE_OFFSET.slice());
let startX = 0;
let startY = 0;
let baseOffset = POPUP_BASE_OFFSET.slice();
const onMove = (evt) => {
this.infoPopup.setOffset([
baseOffset[0] + (evt.clientX - startX),
baseOffset[1] + (evt.clientY - startY),
]);
};
const onUp = (evt) => {
header.releasePointerCapture?.(evt.pointerId);
header.removeEventListener('pointermove', onMove);
header.removeEventListener('pointerup', onUp);
header.removeEventListener('pointercancel', onUp);
header.style.cursor = 'grab';
};
header.addEventListener('pointerdown', (evt) => {
// The close button lives in the header; a press on it is not a drag.
if (evt.target.closest('#info-popup-close') || evt.button !== 0) return;
evt.preventDefault();
startX = evt.clientX;
startY = evt.clientY;
baseOffset = (this.infoPopup.getOffset() || POPUP_BASE_OFFSET).slice();
header.style.cursor = 'grabbing';
header.setPointerCapture?.(evt.pointerId);
header.addEventListener('pointermove', onMove);
header.addEventListener('pointerup', onUp);
header.addEventListener('pointercancel', onUp);
});
}
_buildAnalysisPopupHtml(emoji, title, dataRows) {
let tableRows = '';
for (const row of dataRows) {
@ -1841,7 +1926,7 @@ export class MapView {
}
return `
<div style="background:var(--brand-navy, #1e1a4b);color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;border-radius:10px 10px 0 0;">
<div id="info-popup-header" style="background:var(--brand-navy, #1e1a4b);color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;border-radius:10px 10px 0 0;cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;">
<span>${emoji} ${title}</span>
<button id="info-popup-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
</div>
@ -1884,6 +1969,8 @@ export class MapView {
this.hideInfoPopup();
});
this._makePopupDraggable();
// Summary rows, cleaned for the PDF report (HTML stripped, spacers removed).
// These travel with the export event so the export modal can offer PDF
// alongside the GIS formats — one "Export" entry point instead of two.
@ -2005,9 +2092,13 @@ export class MapView {
scanGroup(this.overlayGroup, 'Overlays');
// Build structured data rows
const radiusFormatted = formatLength(radius);
const areaSqm = Math.PI * radius * radius;
// Build structured data rows. The geometry stores a projected radius, which
// overstates ground distance by 1/cos(latitude) — about 3 m on a 200 m
// circle at Wa. Report the ground figure, so this popup, the tooltip on the
// circle and the PDF export all state the same distance.
const groundRadius = groundFromProjected(radius, circleGeom.getCenter());
const radiusFormatted = formatLength(groundRadius);
const areaSqm = Math.PI * groundRadius * groundRadius;
const areaFormatted = formatArea(areaSqm);
const dataRows = [
@ -3012,13 +3103,20 @@ export class MapView {
});
topoLayer.set('basemapKey', 'topo');
if (!CARTO_API_KEY) {
console.warn(
'[MapView] VITE_CARTO_API_KEY is not set — Carto Light and Carto Dark tiles '
+ 'will carry an "API KEY REQUIRED" watermark. Add it to .env.local and rebuild.'
);
}
const cartoLightLayer = new TileLayer({
title: 'Carto Light',
type: 'base',
zIndex: -100,
visible: defaultBasemap === 'carto-light',
source: new XYZ({
url: 'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',
url: cartoTileUrl('light_all'),
attributions: '© CARTO',
maxZoom: 19,
crossOrigin: 'anonymous',
@ -3032,7 +3130,7 @@ export class MapView {
zIndex: -100,
visible: defaultBasemap === 'carto-dark',
source: new XYZ({
url: 'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
url: cartoTileUrl('dark_all'),
attributions: '© CARTO',
maxZoom: 19,
crossOrigin: 'anonymous',

View File

@ -21,7 +21,8 @@
import GeoJSONFormat from 'ol/format/GeoJSON.js';
import KMLFormat from 'ol/format/KML.js';
import * as shpwrite from 'shp-write';
import { buildShapefileZip } from './shapefile-write.js';
import { downloadBlob } from './zip-store.js';
const PROJ_MAP = 'EPSG:3857';
const PROJ_WGS84 = 'EPSG:4326';
@ -145,89 +146,25 @@ function writeKml(features, rename) {
}
// ---------------------------------------------------------------------------
// Shapefile — uses shp-write to build a .zip with .shp/.shx/.dbf/.prj
// Shapefile — shapefile-write.js builds a .zip of .shp/.shx/.dbf/.prj/.cpg
// ---------------------------------------------------------------------------
/**
* shp-write's `download` accepts a GeoJSON FeatureCollection and a config.
* It splits geometries by type and writes one shapefile per type inside a
* single .zip download. We pre-sanitise field names to honour DBF's 10-char
* limit (otherwise shp-write silently truncates, possibly causing collisions).
* A shapefile holds one geometry type, so the writer splits the collection
* into up to three sets inside a single .zip. Attribute names are sanitised
* to DBF's 10-character limit by the writer itself.
*
* This used to call shp-write's `download()`, which is unusable under Vite:
* see the header of shapefile-write.js for what it did wrong.
*/
async function writeShp(geojson, filenameBase) {
const sanitised = sanitiseShpFieldNames(geojson);
// shp-write 0.3 emits a .zip via DOM download. There's no Promise return,
// but the call is synchronous in practice. Wrap to a Promise so callers
// can await uniformly.
return new Promise((resolve, reject) => {
try {
shpwrite.download(sanitised, {
function writeShp(geojson, filenameBase) {
const blob = buildShapefileZip(geojson, {
folder: filenameBase,
outputType: 'blob',
compression: 'DEFLATE',
types: {
point: `${filenameBase}_point`,
polygon: `${filenameBase}_polygon`,
polyline: `${filenameBase}_line`,
},
});
resolve();
} catch (err) {
reject(err);
}
});
}
/**
* Make every property key valid for DBF: ≤10 chars, alphanumeric/underscore,
* unique across the set. Returns a new GeoJSON FeatureCollection.
*/
function sanitiseShpFieldNames(geojson) {
// Collect the union of source keys (sources are already user-renamed).
const allKeys = new Set();
for (const f of geojson.features) {
for (const k of Object.keys(f.properties || {})) allKeys.add(k);
}
// Build map sourceKey → safeKey.
const used = new Set();
const remap = {};
for (const k of allKeys) {
let base = String(k).replace(/[^A-Za-z0-9_]+/g, '_').slice(0, 10) || 'field';
let candidate = base;
let i = 1;
while (used.has(candidate)) {
const suffix = String(i++);
candidate = base.slice(0, Math.max(1, 10 - suffix.length)) + suffix;
}
used.add(candidate);
remap[k] = candidate;
}
return {
type: 'FeatureCollection',
features: geojson.features.map((f) => {
const newProps = {};
for (const [k, v] of Object.entries(f.properties || {})) {
newProps[remap[k]] = v;
}
return { type: 'Feature', geometry: f.geometry, properties: newProps };
}),
};
}
// ---------------------------------------------------------------------------
// Generic download helper
// ---------------------------------------------------------------------------
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
downloadBlob(blob, `${filenameBase}.zip`);
}

View File

@ -52,6 +52,53 @@ export async function registerServiceWorker() {
}
}
/**
* Remove any service worker registered against this origin, and the caches it
* filled. Used on the development server, where the service worker is not
* registered at all (see initPWA) — without this, a worker installed by an
* earlier session would go on controlling the page for as long as it exists.
*
* It is not only a tidiness problem. On the dev server the worker caches
* `/main.js` and `/src/*.js` cache-first, so it can hand back a stale module
* and fight hot reloading; and in Safari a worker script that reaches the
* browser through a controlling service worker loses its cross-origin-isolation
* headers, so SQLocal's worker is refused and the local database never opens.
*
* @returns {Promise<boolean>} true if anything was removed
*/
export async function unregisterServiceWorkers() {
if (!('serviceWorker' in navigator)) return false;
let removed = false;
try {
for (const registration of await navigator.serviceWorker.getRegistrations()) {
if (await registration.unregister()) removed = true;
}
// Clear the app caches on every load, not only on the load that did the
// unregistering. An unregistered worker goes on controlling the page it is
// already attached to until the next navigation, so it refills those caches
// behind us during that one load; deleting them unconditionally is what
// makes this converge. Nothing in a development build reads them anyway.
//
// The tile caches are left alone: they hold what an officer downloaded for
// field work, they are expensive to refill, and they are equally valid
// with no service worker in sight.
if ('caches' in window) {
const names = await caches.keys();
await Promise.all(
names.filter((name) => !name.startsWith('tiles-')).map((name) => caches.delete(name)),
);
}
if (removed) {
console.log('[PWA] Service worker unregistered (development build) — reload once more to drop it completely');
}
} catch (error) {
console.warn('[PWA] Could not unregister the service worker:', error);
}
return removed;
}
// ============================================================================
// Install Prompt
// ============================================================================
@ -467,6 +514,10 @@ export async function initPWA(options = {}) {
if (autoRegisterSW) {
await registerServiceWorker();
} else {
// Nothing should be controlling the page when registration is off; clear
// out anything an earlier session left behind.
await unregisterServiceWorkers();
}
initInstallPrompt(installButton);

View File

@ -17,7 +17,16 @@
// Most endpoints live under the /spatial_planning module, but not all — the
// contours endpoint sits in its own module. Endpoint names containing a slash
// are resolved against API_ROOT instead (see resolveEndpoint below).
const API_ROOT = 'https://api.lupmis4luspa.org/api';
// Same-origin by default: the PWA host already proxies /api to the ApiEngine
// container (verified 2026-10-12 — /api answers on pwa. and api. only, not on
// the other subdomains, so it is a deliberate location, not a catch-all).
// Same-origin matters here: the app runs cross-origin isolated under
// Cross-Origin-Embedder-Policy: require-corp, and a relative path sidesteps
// mixed content, CORS and CORP entirely. It also keeps the browser on the
// outside-in path the Lands Commission NAT rule requires — the proxy makes the
// internal hop to ApiEngine itself, so the bundle never names a host.
// VITE_API_ROOT overrides it; the dev server proxies /api (see vite.config.js).
const API_ROOT = import.meta.env.VITE_API_ROOT ?? '/api';
const API_BASE = `${API_ROOT}/spatial_planning`;
/**

529
src/shapefile-write.js Normal file
View File

@ -0,0 +1,529 @@
/**
* shapefile-write.js — write ESRI Shapefiles from a GeoJSON FeatureCollection,
* with no dependencies.
*
* Replaces shp-write@0.3.2, which cannot run in a Vite/ESM build:
* • its `dbf` dependency assigns an undeclared `offset` (fine in the sloppy-
* mode browserify bundle it was written for, a ReferenceError under ESM's
* always-on strict mode);
* • src/zip.js branches on `process.browser`, which does not exist in the
* browser;
* • src/download.js navigates to a `data:application/zip` URL, which Chromium
* blocks in the top frame;
* • src/write.js imports node's `assert`, which Vite externalises with a
* build warning.
* It also pulled in JSZip 2.5.0 (2014). Writing the three binary parts here is
* ~300 lines we control, and matches analysis/xlsx.js, which already builds its
* own container rather than depending on that JSZip.
*
* buildShapefileZip(featureCollection, { folder, types }) → Blob (.zip)
*
* Geometries are split by type into up to three layers (point / polyline /
* polygon), because a shapefile holds exactly one geometry type. Coordinates
* are written as-is, so callers must pass EPSG:4326 — the .prj says WGS84.
*
* Format reference: ESRI Shapefile Technical Description (July 1998), and
* dBASE III for the .dbf.
*/
import { zipStore } from './zip-store.js';
const SHP_POINT = 1;
const SHP_POLYLINE = 3;
const SHP_POLYGON = 5;
// Same WKT shp-write emitted; GDAL and QGIS both read it as EPSG:4326.
const PRJ_WGS84 =
'GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137,298.257223563]],' +
'PRIMEM["Greenwich",0],UNIT["Degree",0.017453292519943295]]';
// ---------------------------------------------------------------------------
// Public entry
// ---------------------------------------------------------------------------
/**
* Build a .zip Blob holding one shapefile set (.shp/.shx/.dbf/.prj/.cpg) per
* geometry type present in the collection.
*
* @param {Object} geojson FeatureCollection, coordinates in EPSG:4326.
* @param {Object} [options]
* @param {string} [options.folder] Folder inside the zip; omit for the root.
* @param {{point?: string, polyline?: string, polygon?: string}} [options.types]
* Base filename per layer.
* @returns {Blob}
*/
export function buildShapefileZip(geojson, options = {}) {
const layers = splitByGeometryType(geojson);
const present = Object.keys(layers).filter((k) => layers[k].length > 0);
if (present.length === 0) {
throw new Error('No point, line or polygon geometries to export.');
}
const dir = options.folder ? `${String(options.folder).replace(/\/+$/, '')}/` : '';
const files = {};
for (const kind of present) {
const records = layers[kind];
const base = (options.types && options.types[kind]) || kind;
const { shp, shx } = buildShpShx(records, SHAPE_TYPE[kind]);
files[`${dir}${base}.shp`] = shp;
files[`${dir}${base}.shx`] = shx;
files[`${dir}${base}.dbf`] = buildDbf(records.map((r) => r.properties));
files[`${dir}${base}.prj`] = PRJ_WGS84;
// Tells GDAL/QGIS the .dbf character fields are UTF-8 rather than the
// dBASE default codepage, so non-ASCII attribute values survive.
files[`${dir}${base}.cpg`] = 'UTF-8';
}
return zipStore(files, 'application/zip');
}
const SHAPE_TYPE = {
point: SHP_POINT,
polyline: SHP_POLYLINE,
polygon: SHP_POLYGON,
};
// ---------------------------------------------------------------------------
// GeoJSON → per-layer records
// ---------------------------------------------------------------------------
/**
* Sort features into point / polyline / polygon buckets.
*
* Each record is `{ parts, properties }`, where `parts` is an array of
* coordinate rings (one for a point layer, one per line/ring otherwise).
* Multi-geometries collapse into a single multi-part record, except
* MultiPoint, which shapefiles cannot store in a Point layer — those expand
* into one record per position, repeating the attributes.
*/
function splitByGeometryType(geojson) {
const out = { point: [], polyline: [], polygon: [] };
const features = (geojson && geojson.features) || [];
for (const f of features) {
const props = (f && f.properties) || {};
for (const g of flattenGeometry(f && f.geometry)) {
switch (g.type) {
case 'Point':
if (isXY(g.coordinates)) out.point.push({ parts: [[g.coordinates]], properties: props });
break;
case 'MultiPoint':
for (const pos of g.coordinates || []) {
if (isXY(pos)) out.point.push({ parts: [[pos]], properties: props });
}
break;
case 'LineString': {
const parts = [cleanRing(g.coordinates)].filter((p) => p.length >= 2);
if (parts.length) out.polyline.push({ parts, properties: props });
break;
}
case 'MultiLineString': {
const parts = (g.coordinates || []).map(cleanRing).filter((p) => p.length >= 2);
if (parts.length) out.polyline.push({ parts, properties: props });
break;
}
case 'Polygon': {
const parts = polygonParts(g.coordinates);
if (parts.length) out.polygon.push({ parts, properties: props });
break;
}
case 'MultiPolygon': {
const parts = [];
for (const poly of g.coordinates || []) parts.push(...polygonParts(poly));
if (parts.length) out.polygon.push({ parts, properties: props });
break;
}
default:
break;
}
}
}
return out;
}
/** Expand GeometryCollection; yield plain geometries. */
function* flattenGeometry(geom) {
if (!geom || !geom.type) return;
if (geom.type === 'GeometryCollection') {
for (const g of geom.geometries || []) yield* flattenGeometry(g);
} else {
yield geom;
}
}
function isXY(pos) {
return Array.isArray(pos) && Number.isFinite(pos[0]) && Number.isFinite(pos[1]);
}
/** Drop malformed positions and consecutive duplicates. */
function cleanRing(coords) {
const out = [];
for (const pos of coords || []) {
if (!isXY(pos)) continue;
const last = out[out.length - 1];
if (last && last[0] === pos[0] && last[1] === pos[1]) continue;
out.push([pos[0], pos[1]]);
}
return out;
}
/**
* Prepare one GeoJSON polygon's rings for the shapefile.
*
* Shapefiles orient the opposite way to GeoJSON (RFC 7946): outer rings run
* clockwise and holes counter-clockwise. GeoJSON producers are inconsistent
* about this, so rather than trusting the input we measure each ring's signed
* area and flip it when needed. Rings are also explicitly closed.
*/
function polygonParts(rings) {
const out = [];
(rings || []).forEach((raw, i) => {
const ring = closeRing(cleanRing(raw));
if (ring.length < 4) return; // fewer than 3 distinct vertices
const wantClockwise = i === 0; // ring 0 is the exterior
// Shoelace: positive area means counter-clockwise.
if ((signedArea(ring) > 0) === wantClockwise) ring.reverse();
out.push(ring);
});
return out;
}
function closeRing(ring) {
if (ring.length < 3) return ring;
const first = ring[0], last = ring[ring.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) ring.push([first[0], first[1]]);
return ring;
}
function signedArea(ring) {
let sum = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
sum += (ring[j][0] * ring[i][1]) - (ring[i][0] * ring[j][1]);
}
return sum / 2;
}
// ---------------------------------------------------------------------------
// .shp / .shx
// ---------------------------------------------------------------------------
/**
* Write the geometry (.shp) and its index (.shx). The two share a 100-byte
* header; the index holds one 8-byte offset/length pair per record.
*/
function buildShpShx(records, shapeType) {
const isPoint = shapeType === SHP_POINT;
// Measure first so both buffers can be allocated exactly.
const contentLengths = records.map((r) =>
isPoint ? 20 : 44 + 4 * r.parts.length + 16 * countPoints(r.parts)
);
const shpLength = contentLengths.reduce((n, len) => n + 8 + len, 100);
const shxLength = 100 + 8 * records.length;
const shp = new DataView(new ArrayBuffer(shpLength));
const shx = new DataView(new ArrayBuffer(shxLength));
const box = boundingBox(records);
writeShpHeader(shp, shapeType, shpLength, box);
writeShpHeader(shx, shapeType, shxLength, box);
let offset = 100;
records.forEach((r, i) => {
const len = contentLengths[i];
// Record header: 1-based number and content length, both big-endian, in
// 16-bit words — the only big-endian fields in the format.
shp.setInt32(offset, i + 1);
shp.setInt32(offset + 4, len / 2);
// .shx entry points at the record *header*, not its content.
shx.setInt32(100 + i * 8, offset / 2);
shx.setInt32(100 + i * 8 + 4, len / 2);
writeRecordContent(shp, offset + 8, r, shapeType);
offset += 8 + len;
});
return { shp: new Uint8Array(shp.buffer), shx: new Uint8Array(shx.buffer) };
}
function writeShpHeader(view, shapeType, byteLength, box) {
view.setInt32(0, 9994); // file code (big-endian)
view.setInt32(24, byteLength / 2); // length in 16-bit words
view.setInt32(28, 1000, true); // version
view.setInt32(32, shapeType, true);
view.setFloat64(36, box.xmin, true);
view.setFloat64(44, box.ymin, true);
view.setFloat64(52, box.xmax, true);
view.setFloat64(60, box.ymax, true);
// Bytes 68..99 are the Z and M ranges, left at zero for 2D shapes.
}
function writeRecordContent(view, at, record, shapeType) {
if (shapeType === SHP_POINT) {
const [x, y] = record.parts[0][0];
view.setInt32(at, SHP_POINT, true);
view.setFloat64(at + 4, x, true);
view.setFloat64(at + 12, y, true);
return;
}
const box = boundingBox([record]);
const numPoints = countPoints(record.parts);
view.setInt32(at, shapeType, true);
view.setFloat64(at + 4, box.xmin, true);
view.setFloat64(at + 12, box.ymin, true);
view.setFloat64(at + 20, box.xmax, true);
view.setFloat64(at + 28, box.ymax, true);
view.setInt32(at + 36, record.parts.length, true);
view.setInt32(at + 40, numPoints, true);
// Part index: the position, in the flat point list, where each part starts.
let partsAt = at + 44;
let start = 0;
for (const part of record.parts) {
view.setInt32(partsAt, start, true);
partsAt += 4;
start += part.length;
}
let pointsAt = partsAt;
for (const part of record.parts) {
for (const [x, y] of part) {
view.setFloat64(pointsAt, x, true);
view.setFloat64(pointsAt + 8, y, true);
pointsAt += 16;
}
}
}
function countPoints(parts) {
return parts.reduce((n, p) => n + p.length, 0);
}
function boundingBox(records) {
let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity;
for (const r of records) {
for (const part of r.parts) {
for (const [x, y] of part) {
if (x < xmin) xmin = x;
if (y < ymin) ymin = y;
if (x > xmax) xmax = x;
if (y > ymax) ymax = y;
}
}
}
if (!Number.isFinite(xmin)) return { xmin: 0, ymin: 0, xmax: 0, ymax: 0 };
return { xmin, ymin, xmax, ymax };
}
// ---------------------------------------------------------------------------
// .dbf
// ---------------------------------------------------------------------------
const DBF_MAX_CHAR = 254; // dBASE III character field limit
const DBF_MAX_NUMERIC = 18; // digits a numeric field can hold safely
const DBF_MAX_DECIMALS = 10;
/**
* Write the attribute table as a dBASE III .dbf.
*
* Column types are inferred from the values: all-boolean → L, all-numeric → N,
* anything else → C. Field names are sanitised to DBF's 10-character
* alphanumeric limit and de-duplicated.
*/
function buildDbf(rows) {
const fields = inferFields(rows);
const enc = new TextEncoder();
const headerLength = 32 + 32 * fields.length + 1;
const recordLength = fields.reduce((n, f) => n + f.size, 1); // +1 delete flag
const total = headerLength + rows.length * recordLength + 1; // +1 EOF marker
const buf = new ArrayBuffer(total);
const view = new DataView(buf);
const bytes = new Uint8Array(buf);
const now = new Date();
view.setUint8(0, 0x03); // dBASE III, no memo
view.setUint8(1, now.getFullYear() - 1900);
view.setUint8(2, now.getMonth() + 1);
view.setUint8(3, now.getDate());
view.setUint32(4, rows.length, true);
view.setUint16(8, headerLength, true);
view.setUint16(10, recordLength, true);
fields.forEach((f, i) => {
const at = 32 + i * 32;
// Name: up to 10 ASCII bytes, null-terminated within an 11-byte slot.
const name = enc.encode(f.name).subarray(0, 10);
bytes.set(name, at);
view.setUint8(at + 11, f.type.charCodeAt(0));
view.setUint8(at + 16, f.size);
view.setUint8(at + 17, f.decimals);
});
view.setUint8(32 + fields.length * 32, 0x0D); // field descriptor terminator
let at = headerLength;
rows.forEach((row, i) => {
view.setUint8(at, 0x20); // 0x20 = record is live
at += 1;
for (const f of fields) {
const value = f.sequential ? i + 1 : (row ? row[f.source] : undefined);
bytes.set(formatDbfValue(enc, f, value), at);
at += f.size;
}
});
view.setUint8(at, 0x1A); // end-of-file marker
return bytes;
}
/** Decide a name, type and width for every column across all rows. */
function inferFields(rows) {
const names = [];
for (const row of rows) {
for (const k of Object.keys(row || {})) if (!names.includes(k)) names.push(k);
}
// A shapefile with no attributes at all is awkward for some readers, so fall
// back to a sequential id column.
if (names.length === 0) {
return [{ name: 'FID', source: null, type: 'N', size: 9, decimals: 0, sequential: true }];
}
const used = new Set();
return names.map((source) => {
const field = describeColumn(rows.map((r) => (r ? r[source] : undefined)));
field.source = source;
field.name = uniqueFieldName(source, used);
return field;
});
}
/** DBF names are ≤10 chars of [A-Za-z0-9_] and must be unique in the table. */
function uniqueFieldName(source, used) {
const base = String(source).replace(/[^A-Za-z0-9_]+/g, '_').slice(0, 10) || 'field';
let candidate = base;
let i = 1;
while (used.has(candidate.toUpperCase())) {
const suffix = String(i++);
candidate = base.slice(0, Math.max(1, 10 - suffix.length)) + suffix;
}
used.add(candidate.toUpperCase());
return candidate;
}
function describeColumn(values) {
const enc = new TextEncoder();
let sawValue = false, allBool = true, allNumber = true;
let decimals = 0, numericWidth = 1, charWidth = 1;
for (const v of values) {
if (v === null || v === undefined || v === '') continue;
sawValue = true;
if (typeof v !== 'boolean') allBool = false;
if (typeof v === 'number' && Number.isFinite(v)) {
const d = decimalsNeeded(v);
if (d > decimals) decimals = d;
} else {
allNumber = false;
}
const text = v instanceof Date ? v.toISOString() : String(v);
charWidth = Math.max(charWidth, enc.encode(text).length);
}
if (!sawValue) return { type: 'C', size: 1, decimals: 0 };
if (allBool) return { type: 'L', size: 1, decimals: 0 };
if (allNumber) {
// Width must cover the widest value once every value is rendered at the
// column's shared decimal count.
let representable = true;
for (const v of values) {
if (typeof v !== 'number' || !Number.isFinite(v)) continue;
const text = plainFixed(v, decimals);
if (text === null) { representable = false; break; }
numericWidth = Math.max(numericWidth, text.length);
}
if (representable && numericWidth <= DBF_MAX_NUMERIC) {
return { type: 'N', size: numericWidth, decimals };
}
// Too wide for a numeric column (huge magnitudes) — keep it as text, where
// String(v) at least stays readable instead of silently losing digits.
}
return { type: 'C', size: Math.min(charWidth, DBF_MAX_CHAR), decimals: 0 };
}
/**
* Render a number as plain decimal text, or null if it cannot be.
*
* DBF numerics are ASCII digits, but Number#toFixed switches to exponential
* notation at 1e21 — writing "1.234e+25" into an N column produces a field no
* reader can parse. Such values belong in a character column instead.
*/
function plainFixed(v, decimals) {
const s = v.toFixed(decimals);
return (s.includes('e') || s.includes('E')) ? null : s;
}
/** Fewest decimal places that still round-trip the value, capped. */
function decimalsNeeded(v) {
if (Number.isInteger(v)) return 0;
for (let d = 1; d < DBF_MAX_DECIMALS; d++) {
if (Number(v.toFixed(d)) === v) return d;
}
return DBF_MAX_DECIMALS;
}
/** Render one value into exactly `field.size` bytes. */
function formatDbfValue(enc, field, value) {
const out = new Uint8Array(field.size).fill(0x20); // space-padded
if (field.type === 'L') {
out[0] = value === null || value === undefined || value === ''
? 0x3F // '?' = unknown
: (value ? 0x54 : 0x46); // 'T' / 'F'
return out;
}
if (value === null || value === undefined || value === '') return out;
if (field.type === 'N') {
const text = typeof value === 'number' && Number.isFinite(value)
? plainFixed(value, field.decimals)
: null;
// Numerics are right-justified; anything that still overflows is left
// blank rather than truncated, since half a number is worse than none.
const b = text === null ? null : enc.encode(text);
if (b && b.length && b.length <= field.size) out.set(b, field.size - b.length);
return out;
}
const text = value instanceof Date ? value.toISOString() : String(value);
out.set(truncateUtf8(enc, text, field.size), 0);
return out;
}
/** Encode to at most `max` bytes without splitting a multi-byte character. */
function truncateUtf8(enc, text, max) {
const bytes = enc.encode(text);
if (bytes.length <= max) return bytes;
let end = max;
while (end > 0 && (bytes[end] & 0xC0) === 0x80) end--; // back off continuation bytes
return bytes.subarray(0, end);
}

View File

@ -121,3 +121,52 @@ export function formatAreaFull(sqMetres) {
export function formatCircleExtent(radiusMetres) {
return formatArea(Math.PI * radiusMetres * radiusMetres);
}
// ---------------------------------------------------------------------------
// Web Mercator scale correction
// ---------------------------------------------------------------------------
// The map works in EPSG:3857, where a "metre" is not a metre on the ground: the
// projection stretches distance by 1/cos(latitude). The Circle tool reads its
// radius straight from geometry.getRadius(), so the figure it reports has
// always been the projected value. Nobody noticed while dragging, because the
// label and the shape agreed with each other — but the moment an officer types
// 200 they are asserting a ground distance, and 200 projected metres is 197 m
// on the ground at Wa and 199 m at Accra.
//
// ground = projected x cos(latitude) (see groundFromProjected)
// projected = ground / cos(latitude) (see projectedFromGround)
//
// The latitude of the circle's centre is used for the whole circle. Over a few
// hundred metres the variation across the shape is far below the precision
// anyone reads off the screen.
import { toLonLat } from 'ol/proj';
/** Scale factor at a map coordinate: ground metres per projected metre. */
export function mercatorScaleAt(coordinate3857) {
const lat = toLonLat(coordinate3857)[1];
return Math.cos((lat * Math.PI) / 180);
}
/** Projected length -> true ground length, at the given map coordinate. */
export function groundFromProjected(projectedMetres, coordinate3857) {
return projectedMetres * mercatorScaleAt(coordinate3857);
}
/** Ground length the user typed -> the projected length to give the geometry. */
export function projectedFromGround(groundMetres, coordinate3857) {
const scale = mercatorScaleAt(coordinate3857);
return scale === 0 ? groundMetres : groundMetres / scale;
}
/**
* A sensible step for nudging a radius, so one tap means something at 20 m and
* at 2 km. Values snap to the step, so repeated taps land on round numbers.
*/
export function radiusStep(groundMetres) {
if (groundMetres < 50) return 1;
if (groundMetres < 200) return 5;
if (groundMetres < 1000) return 10;
if (groundMetres < 5000) return 50;
return 100;
}

112
src/zip-store.js Normal file
View File

@ -0,0 +1,112 @@
/**
* zip-store.js — a minimal, dependency-free STORED (uncompressed) ZIP writer.
*
* Extracted from analysis/xlsx.js so the shapefile writer can share it. Both
* produce small archives (a few analysis rows, a handful of drawn features)
* where DEFLATE would save nothing worth a compression dependency, and every
* reader — Excel, QGIS, GDAL, unzip — accepts stored entries.
*
* zipStore({ 'a/b.txt': 'hello', 'a/c.bin': new Uint8Array([1, 2]) })
*
* Values may be strings (encoded UTF-8), Uint8Array, or ArrayBuffer.
*/
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes) {
let c = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
function toBytes(enc, value) {
if (value instanceof Uint8Array) return value;
if (value instanceof ArrayBuffer) return new Uint8Array(value);
if (ArrayBuffer.isView(value)) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
return enc.encode(String(value));
}
/**
* Pack files (path → string | Uint8Array | ArrayBuffer) into an uncompressed
* ZIP Blob.
*
* @param {Record<string, string|Uint8Array|ArrayBuffer>} files
* @param {string} [mimeType] Blob type for the result.
* @returns {Blob}
*/
export function zipStore(files, mimeType = 'application/zip') {
const enc = new TextEncoder();
const entries = Object.entries(files).map(([name, value]) => {
const nameBytes = enc.encode(name);
return {
nameBytes,
data: toBytes(enc, value),
// Bit 11 declares the name is UTF-8. Only set when it matters, so
// pure-ASCII archives keep byte-for-byte the output xlsx.js had before.
flags: nameBytes.length === name.length ? 0 : 0x0800,
};
});
// DOS time/date — a fixed timestamp keeps output deterministic.
const time = 0, date = ((2020 - 1980) << 9) | (1 << 5) | 1;
const chunks = [];
const central = [];
let offset = 0;
const u16 = (v) => [v & 0xFF, (v >>> 8) & 0xFF];
const u32 = (v) => [v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF];
for (const e of entries) {
const crc = crc32(e.data);
const size = e.data.length;
const local = [
...u32(0x04034b50), ...u16(20), ...u16(e.flags), ...u16(0),
...u16(time), ...u16(date), ...u32(crc), ...u32(size), ...u32(size),
...u16(e.nameBytes.length), ...u16(0),
];
chunks.push(new Uint8Array(local), e.nameBytes, e.data);
central.push([
...u32(0x02014b50), ...u16(20), ...u16(20), ...u16(e.flags), ...u16(0),
...u16(time), ...u16(date), ...u32(crc), ...u32(size), ...u32(size),
...u16(e.nameBytes.length), ...u16(0), ...u16(0), ...u16(0), ...u16(0),
...u32(0), ...u32(offset),
]);
central.push(e.nameBytes);
offset += local.length + e.nameBytes.length + size;
}
const cdParts = [];
let cdSize = 0;
for (const part of central) {
const arr = part instanceof Uint8Array ? part : new Uint8Array(part);
cdParts.push(arr); cdSize += arr.length;
}
const end = new Uint8Array([
...u32(0x06054b50), ...u16(0), ...u16(0),
...u16(entries.length), ...u16(entries.length),
...u32(cdSize), ...u32(offset), ...u16(0),
]);
return new Blob([...chunks, ...cdParts, end], { type: mimeType });
}
/** Trigger a browser download of a Blob. */
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}

View File

@ -16,6 +16,18 @@ export default defineConfig({
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
// In production the PWA host proxies /api to the ApiEngine container, so
// the app calls the API with a relative path. The dev server is not behind
// that proxy, so it forwards /api to the public host — which is correct
// here because a developer's machine is outside the server, and only
// callers inside it are blocked from using the public name.
proxy: {
'/api': {
target: 'https://api.lupmis4luspa.org',
changeOrigin: true,
secure: true,
},
},
},
// Preview server (npm run preview) configuration
@ -50,6 +62,14 @@ export default defineConfig({
// Without its own chunk, Rollup assigns it to one of them and the two
// chunks reference each other ("Circular chunk: jspdf -> geotiff").
if (id.includes('node_modules/pako/')) return 'pako';
// Vite's preload helper (the `__vitePreload` used by every dynamic
// import()) is a virtual module with no node_modules path, so Rollup
// is free to park it in whichever chunk it likes — it used to land in
// the geotiff chunk. The entry needs the helper at startup, so that
// made the browser preload all ~257 kB of geotiff.js on every visit,
// defeating the lazy rule below. Give the helper a chunk of its own
// (~1 kB) so the entry pulls in only that.
if (id.includes('vite/preload-helper')) return 'vite-helpers';
// Turf.js — only needed when the Analysis panel is opened, which
// main.js imports dynamically. Keep it out of the eager bundle.
if (id.includes('node_modules/@turf/')) return 'turf';
@ -63,9 +83,11 @@ export default defineConfig({
if (id.includes('node_modules/ol/')) return 'openlayers';
if (id.includes('node_modules/ol-ext/')) return 'ol-ext';
if (id.includes('node_modules/bootstrap/')) return 'bootstrap';
// shpjs (+ its jszip dependency) — dynamically imported at runtime,
// so this chunk is only fetched when the user imports a shapefile.
if (id.includes('node_modules/shpjs/') || id.includes('node_modules/jszip/')) return 'shpjs';
// shpjs — dynamically imported at runtime (main.js), so this chunk is
// only fetched when the user imports a shapefile. Writing shapefiles
// needs no dependency at all (src/shapefile-write.js), and shpjs 6
// unzips with but-unzip, so nothing here reaches JSZip any more.
if (id.includes('node_modules/shpjs/')) return 'shpjs';
if (id.includes('node_modules/jspdf')) return 'jspdf';
},
},