Compare commits
26 Commits
4edb0a2f37
...
ea3ad5726b
| Author | SHA1 | Date | |
|---|---|---|---|
| ea3ad5726b | |||
| 1f8b0c96c0 | |||
| 4952c92e42 | |||
| e82f4cae60 | |||
| 7a6bf9b022 | |||
| 7ce20bd498 | |||
| 4809ef324a | |||
| 4132a12b38 | |||
| 78c26dab5a | |||
| dd6f0783bb | |||
| 3ca6d72c98 | |||
| ce6c922554 | |||
| adbf9ba211 | |||
| 8674ca4df2 | |||
| 8d1d874ad7 | |||
| d8b635b442 | |||
| 26c8438e04 | |||
| 486480eda6 | |||
| 88f55ade46 | |||
| 9840180ba3 | |||
| f4c56e5078 | |||
| 7369f62e92 | |||
| 34cc2563a3 | |||
| 90a5a01b05 | |||
| 631b316118 | |||
| 9b4c7625d2 |
8
.env.example
Normal file
8
.env.example
Normal 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.
BIN
LUPMIS2_Database_Schema_Summary.docx
Normal file
BIN
LUPMIS2_Database_Schema_Summary.docx
Normal file
Binary file not shown.
Binary file not shown.
BIN
LUPMIS2_ECMWF_Open_Data_Assessment.docx
Normal file
BIN
LUPMIS2_ECMWF_Open_Data_Assessment.docx
Normal file
Binary file not shown.
BIN
LUPMIS2_Open_Issues_Estimates.docx
Normal file
BIN
LUPMIS2_Open_Issues_Estimates.docx
Normal file
Binary file not shown.
BIN
LUPMIS2_Schema_ERM.png
Normal file
BIN
LUPMIS2_Schema_ERM.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 413 KiB |
147
LUPMIS2_Schema_ERM.svg
Normal file
147
LUPMIS2_Schema_ERM.svg
Normal 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
BIN
LUPMIS2_Schema_Map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
61
LUPMIS2_Schema_Map.svg
Normal file
61
LUPMIS2_Schema_Map.svg
Normal 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 |
BIN
LUPMIS2_Topic0_Workstation_Setup.docx
Normal file
BIN
LUPMIS2_Topic0_Workstation_Setup.docx
Normal file
Binary file not shown.
BIN
LUPMIS2_Topic1_Web_Foundations_Exercises.docx
Normal file
BIN
LUPMIS2_Topic1_Web_Foundations_Exercises.docx
Normal file
Binary file not shown.
BIN
LUPMIS2_Topic2_Version_Control_Exercises.docx
Normal file
BIN
LUPMIS2_Topic2_Version_Control_Exercises.docx
Normal file
Binary file not shown.
BIN
LUPMIS2_Topic3_Frontend_Tooling_Exercises.docx
Normal file
BIN
LUPMIS2_Topic3_Frontend_Tooling_Exercises.docx
Normal file
Binary file not shown.
BIN
LUPMIS2_Topic4_OpenLayers_Exercises.docx
Normal file
BIN
LUPMIS2_Topic4_OpenLayers_Exercises.docx
Normal file
Binary file not shown.
BIN
LUPMIS2_Topic5_Editing_Tools_Exercises.docx
Normal file
BIN
LUPMIS2_Topic5_Editing_Tools_Exercises.docx
Normal file
Binary file not shown.
BIN
LUPMIS2_Topic6_PWA_Offline_Exercises.docx
Normal file
BIN
LUPMIS2_Topic6_PWA_Offline_Exercises.docx
Normal file
Binary file not shown.
BIN
LUPMIS2_Topic7_PostGIS_Exercises.docx
Normal file
BIN
LUPMIS2_Topic7_PostGIS_Exercises.docx
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
LUPMIS2_Work_Breakdown_Open_Scope.xlsx
Normal file
BIN
LUPMIS2_Work_Breakdown_Open_Scope.xlsx
Normal file
Binary file not shown.
8
dist/.htaccess
vendored
8
dist/.htaccess
vendored
@ -70,6 +70,14 @@ DirectoryIndex index.php index.html
|
|||||||
# index.php. Query strings (?mode=permit&...) pass through automatically.
|
# index.php. Query strings (?mode=permit&...) pass through automatically.
|
||||||
RewriteRule ^embed/?$ embed.php [L]
|
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
|
# 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
|
# 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.
|
# client-side. Comment out this block if hash-based routing is preferred.
|
||||||
|
|||||||
12
dist/assets/analysis-modal-BJxejC9Q.js
vendored
Normal file
12
dist/assets/analysis-modal-BJxejC9Q.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/analysis-modal-BJxejC9Q.js.map
vendored
Normal file
1
dist/assets/analysis-modal-BJxejC9Q.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
11
dist/assets/analysis-modal-BcHNiMgQ.js
vendored
11
dist/assets/analysis-modal-BcHNiMgQ.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/analysis-modal-BcHNiMgQ.js.map
vendored
1
dist/assets/analysis-modal-BcHNiMgQ.js.map
vendored
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
1
dist/assets/geotiff-B_Wfowsp.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/geotiff-BaoeLn6q.js.map
vendored
1
dist/assets/geotiff-BaoeLn6q.js.map
vendored
File diff suppressed because one or more lines are too long
927
dist/assets/index-ErUYnuzo.js
vendored
927
dist/assets/index-ErUYnuzo.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-ErUYnuzo.js.map
vendored
1
dist/assets/index-ErUYnuzo.js.map
vendored
File diff suppressed because one or more lines are too long
933
dist/assets/index-ybgDODsx.js
vendored
Normal file
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
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
2
dist/assets/pako-Xa-UToif.js
vendored
2
dist/assets/pako-Xa-UToif.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/pako-Xa-UToif.js.map
vendored
1
dist/assets/pako-Xa-UToif.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/assets/pako-n3Pgozwg.js
vendored
Normal file
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
1
dist/assets/pako-n3Pgozwg.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
2
dist/assets/pdf-export-BONdNTHD.js
vendored
2
dist/assets/pdf-export-BONdNTHD.js
vendored
@ -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
2
dist/assets/pdf-export-DJBZ6C0H.js
vendored
Normal 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
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
1
dist/assets/shpjs-CNrRgkgn.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
6
dist/assets/shpjs-Doisr2ug.js
vendored
6
dist/assets/shpjs-Doisr2ug.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/shpjs-Doisr2ug.js.map
vendored
1
dist/assets/shpjs-Doisr2ug.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/assets/vite-helpers-BlTxHScW.js
vendored
Normal file
2
dist/assets/vite-helpers-BlTxHScW.js
vendored
Normal 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
|
||||||
1
dist/assets/vite-helpers-BlTxHScW.js.map
vendored
Normal file
1
dist/assets/vite-helpers-BlTxHScW.js.map
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"vite-helpers-BlTxHScW.js","sources":[],"sourcesContent":[],"names":[],"mappings":""}
|
||||||
30
dist/assets/xlsx-d1LrY7GM.js
vendored
30
dist/assets/xlsx-d1LrY7GM.js
vendored
@ -1,30 +0,0 @@
|
|||||||
function u(n){return String(n).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'").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
|
|
||||||
1
dist/assets/xlsx-d1LrY7GM.js.map
vendored
1
dist/assets/xlsx-d1LrY7GM.js.map
vendored
File diff suppressed because one or more lines are too long
30
dist/assets/xlsx-x-C7b9wf.js
vendored
Normal file
30
dist/assets/xlsx-x-C7b9wf.js
vendored
Normal 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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'").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
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
12
dist/embed.php
vendored
@ -35,7 +35,17 @@ header_remove('X-Frame-Options');
|
|||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
||||||
$plainToken = $_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 = curl_init();
|
||||||
curl_setopt_array($curl, [
|
curl_setopt_array($curl, [
|
||||||
|
|||||||
107
dist/index.html
vendored
107
dist/index.html
vendored
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#005eb8">
|
<meta name="theme-color" content="#005eb8">
|
||||||
<meta name="description" content="LUPMIS2 Drawing Tools">
|
<meta name="description" content="LUPMIS2 GIS Tools">
|
||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
@ -61,7 +61,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<title>LUPMIS2 Drawing Tools</title>
|
<title>LUPMIS2 GIS Tools</title>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* LUSPA Design System Tokens
|
/* LUSPA Design System Tokens
|
||||||
@ -1443,6 +1443,86 @@
|
|||||||
border-top-color: var(--primary);
|
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 */
|
/* Arrow pointing to measurement point */
|
||||||
.measure-tooltip::before {
|
.measure-tooltip::before {
|
||||||
content: '';
|
content: '';
|
||||||
@ -1735,8 +1815,15 @@
|
|||||||
block to the rail itself, so `left: 100%` clears the whole panel. The
|
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)
|
flyout then always appears at the same place (rail's right edge, centred)
|
||||||
instead of jumping to whichever button was pressed.
|
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;
|
position: static;
|
||||||
}
|
}
|
||||||
.ol-editbar-rail .ol-option-bar {
|
.ol-editbar-rail .ol-option-bar {
|
||||||
@ -1917,15 +2004,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/index-ErUYnuzo.js"></script>
|
<script type="module" crossorigin src="/assets/index-ybgDODsx.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/openlayers-J9qS6Th1.js">
|
<link rel="modulepreload" crossorigin href="/assets/vite-helpers-BlTxHScW.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pako-Xa-UToif.js">
|
|
||||||
<link rel="modulepreload" crossorigin href="/assets/geotiff-BaoeLn6q.js">
|
|
||||||
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
|
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/ol-ext-C5frl4dY.js">
|
<link rel="modulepreload" crossorigin href="/assets/openlayers-D5-VSKzq.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/shpjs-Doisr2ug.js">
|
<link rel="modulepreload" crossorigin href="/assets/ol-ext-DV70ljNY.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/openlayers-BtPuoxOl.css">
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/bootstrap-BtmJYOxZ.css">
|
<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/ol-ext-BgKrOIxx.css">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Dp-9_Fz_.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Dp-9_Fz_.css">
|
||||||
</head>
|
</head>
|
||||||
@ -1935,7 +2020,7 @@
|
|||||||
<nav class="navbar py-2">
|
<nav class="navbar py-2">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<span class="navbar-brand mb-0 h1">
|
<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>
|
</span>
|
||||||
|
|
||||||
<!-- Live GPS status: lon/lat, accuracy (precision) and satellites.
|
<!-- Live GPS status: lon/lat, accuracy (precision) and satellites.
|
||||||
|
|||||||
12
dist/index.php
vendored
12
dist/index.php
vendored
@ -69,7 +69,17 @@ if (isset($_GET['logout'])) {
|
|||||||
// cleared the SSO cookie, which left the stale district pinned).
|
// cleared the SSO cookie, which left the stale district pinned).
|
||||||
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
||||||
$plainToken = $_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 = curl_init();
|
||||||
curl_setopt_array($curl, [
|
curl_setopt_array($curl, [
|
||||||
|
|||||||
2
dist/manifest.json
vendored
2
dist/manifest.json
vendored
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "LUPMIS2 Drawing Tools",
|
"name": "LUPMIS2 GIS Tools",
|
||||||
"short_name": "LUPMIS2",
|
"short_name": "LUPMIS2",
|
||||||
"description": "Map and GIS functions for Land Use Planning in Ghana",
|
"description": "Map and GIS functions for Land Use Planning in Ghana",
|
||||||
"start_url": "./",
|
"start_url": "./",
|
||||||
|
|||||||
195
dist/sw.js
vendored
195
dist/sw.js
vendored
@ -130,7 +130,61 @@
|
|||||||
// not only in the moment a location is saved. Previously a reload put the
|
// 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
|
// layer back to hidden and every marker vanished. An empty district still
|
||||||
// opens with the layer off.
|
// 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 SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
||||||
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
||||||
const API_CACHE = `api-${CACHE_VERSION}`;
|
const API_CACHE = `api-${CACHE_VERSION}`;
|
||||||
@ -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,
|
// 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).
|
// 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}`;
|
// Tile caches are versioned separately from the app. They used to take their
|
||||||
const TILES_SATELLITE = `tiles-satellite-${CACHE_VERSION}`;
|
// name from CACHE_VERSION, and the activate handler deletes every tiles-* cache
|
||||||
const TILES_CARTO_LIGHT = `tiles-carto-light-${CACHE_VERSION}`;
|
// whose name is not current — so each release silently threw away every tile an
|
||||||
const TILES_CARTO_DARK = `tiles-carto-dark-${CACHE_VERSION}`;
|
// 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.
|
// Per-provider tile limits.
|
||||||
// • OSM and Topographic are the providers offered for active offline
|
// • 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.
|
// 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 = [
|
const SHELL_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
@ -221,36 +295,89 @@ self.addEventListener('install', (event) => {
|
|||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
console.log('[SW] Activating...');
|
console.log('[SW] Activating...');
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil((async () => {
|
||||||
caches.keys()
|
const cacheNames = await caches.keys();
|
||||||
.then((cacheNames) => {
|
|
||||||
// Build the set of caches that should remain
|
|
||||||
const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]);
|
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
|
cacheNames
|
||||||
// Delete anything that:
|
.filter((name) =>
|
||||||
// • belongs to one of our managed cache prefixes (shell-, tiles-, modules-, api-)
|
(name.startsWith('shell-') || name.startsWith('modules-') || name.startsWith('api-'))
|
||||||
// • but is NOT in the current keep set
|
&& !keep.has(name))
|
||||||
// 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);
|
|
||||||
})
|
|
||||||
.map((name) => {
|
.map((name) => {
|
||||||
console.log('[SW] Deleting old cache:', name);
|
console.log('[SW] Deleting old cache:', name);
|
||||||
return caches.delete(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
|
// FETCH EVENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -265,11 +392,17 @@ self.addEventListener('fetch', (event) => {
|
|||||||
// Skip chrome-extension and other non-http(s) requests
|
// Skip chrome-extension and other non-http(s) requests
|
||||||
if (!url.protocol.startsWith('http')) return;
|
if (!url.protocol.startsWith('http')) return;
|
||||||
|
|
||||||
// Skip worker files and Vite dev-server node_modules requests —
|
// Skip the Vite dev server's own module plumbing — it serves worker scripts
|
||||||
// intercepting these breaks module workers (e.g. SQLocal/SQLite).
|
// straight out of node_modules with a ?worker_file query, and intercepting
|
||||||
if (url.pathname.includes('node_modules') ||
|
// those breaks module workers (e.g. SQLocal/SQLite).
|
||||||
url.search.includes('worker_file') ||
|
if (url.pathname.includes('node_modules') || url.search.includes('worker_file')) return;
|
||||||
request.destination === 'worker') 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) -----
|
// ----- TILE REQUESTS — passive cache-then-network (per-host bucket) -----
|
||||||
const tileCache = getTileCacheName(url);
|
const tileCache = getTileCacheName(url);
|
||||||
|
|||||||
95
index.html
95
index.html
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#005eb8">
|
<meta name="theme-color" content="#005eb8">
|
||||||
<meta name="description" content="LUPMIS2 Drawing Tools">
|
<meta name="description" content="LUPMIS2 GIS Tools">
|
||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
@ -61,7 +61,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<title>LUPMIS2 Drawing Tools</title>
|
<title>LUPMIS2 GIS Tools</title>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* LUSPA Design System Tokens
|
/* LUSPA Design System Tokens
|
||||||
@ -1443,6 +1443,86 @@
|
|||||||
border-top-color: var(--primary);
|
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 */
|
/* Arrow pointing to measurement point */
|
||||||
.measure-tooltip::before {
|
.measure-tooltip::before {
|
||||||
content: '';
|
content: '';
|
||||||
@ -1735,8 +1815,15 @@
|
|||||||
block to the rail itself, so `left: 100%` clears the whole panel. The
|
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)
|
flyout then always appears at the same place (rail's right edge, centred)
|
||||||
instead of jumping to whichever button was pressed.
|
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;
|
position: static;
|
||||||
}
|
}
|
||||||
.ol-editbar-rail .ol-option-bar {
|
.ol-editbar-rail .ol-option-bar {
|
||||||
@ -1924,7 +2011,7 @@
|
|||||||
<nav class="navbar py-2">
|
<nav class="navbar py-2">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<span class="navbar-brand mb-0 h1">
|
<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>
|
</span>
|
||||||
|
|
||||||
<!-- Live GPS status: lon/lat, accuracy (precision) and satellites.
|
<!-- Live GPS status: lon/lat, accuracy (precision) and satellites.
|
||||||
|
|||||||
11
main.js
11
main.js
@ -218,10 +218,19 @@ async function initApp() {
|
|||||||
if (showNoDistrictBlockerIfNeeded()) return;
|
if (showNoDistrictBlockerIfNeeded()) return;
|
||||||
|
|
||||||
// 1. Initialize PWA features (Service Worker, install prompt, offline detection)
|
// 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({
|
await initPWA({
|
||||||
installButton: '#install-btn',
|
installButton: '#install-btn',
|
||||||
offlineIndicator: '#offline-indicator',
|
offlineIndicator: '#offline-indicator',
|
||||||
autoRegisterSW: true
|
autoRegisterSW: import.meta.env.PROD,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Initialize the map
|
// 2. Initialize the map
|
||||||
|
|||||||
41
package-lock.json
generated
41
package-lock.json
generated
@ -23,7 +23,6 @@
|
|||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"ol": "^10.3.0",
|
"ol": "^10.3.0",
|
||||||
"ol-ext": "^4.0.24",
|
"ol-ext": "^4.0.24",
|
||||||
"shp-write": "^0.3.2",
|
|
||||||
"shpjs": "^6.2.0",
|
"shpjs": "^6.2.0",
|
||||||
"sqlocal": "^0.16.0"
|
"sqlocal": "^0.16.0"
|
||||||
},
|
},
|
||||||
@ -1176,15 +1175,6 @@
|
|||||||
"utrie": "^1.0.2"
|
"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": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||||
@ -1337,11 +1327,6 @@
|
|||||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/jspdf": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
|
||||||
@ -1368,21 +1353,6 @@
|
|||||||
"jspdf": "^2 || ^3 || ^4"
|
"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": {
|
"node_modules/lerc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
||||||
@ -1684,17 +1654,6 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/shpjs": {
|
||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/shpjs/-/shpjs-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/shpjs/-/shpjs-6.2.0.tgz",
|
||||||
|
|||||||
@ -24,7 +24,6 @@
|
|||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"ol": "^10.3.0",
|
"ol": "^10.3.0",
|
||||||
"ol-ext": "^4.0.24",
|
"ol-ext": "^4.0.24",
|
||||||
"shp-write": "^0.3.2",
|
|
||||||
"shpjs": "^6.2.0",
|
"shpjs": "^6.2.0",
|
||||||
"sqlocal": "^0.16.0"
|
"sqlocal": "^0.16.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -70,6 +70,14 @@ DirectoryIndex index.php index.html
|
|||||||
# index.php. Query strings (?mode=permit&...) pass through automatically.
|
# index.php. Query strings (?mode=permit&...) pass through automatically.
|
||||||
RewriteRule ^embed/?$ embed.php [L]
|
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
|
# 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
|
# 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.
|
# client-side. Comment out this block if hash-based routing is preferred.
|
||||||
|
|||||||
@ -35,7 +35,17 @@ header_remove('X-Frame-Options');
|
|||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
||||||
$plainToken = $_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 = curl_init();
|
||||||
curl_setopt_array($curl, [
|
curl_setopt_array($curl, [
|
||||||
|
|||||||
@ -69,7 +69,17 @@ if (isset($_GET['logout'])) {
|
|||||||
// cleared the SSO cookie, which left the stale district pinned).
|
// cleared the SSO cookie, which left the stale district pinned).
|
||||||
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
if (!isset($_SESSION['user_id']) && isset($_COOKIE['sso_auth_token'])) {
|
||||||
$plainToken = $_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 = curl_init();
|
||||||
curl_setopt_array($curl, [
|
curl_setopt_array($curl, [
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "LUPMIS2 Drawing Tools",
|
"name": "LUPMIS2 GIS Tools",
|
||||||
"short_name": "LUPMIS2",
|
"short_name": "LUPMIS2",
|
||||||
"description": "Map and GIS functions for Land Use Planning in Ghana",
|
"description": "Map and GIS functions for Land Use Planning in Ghana",
|
||||||
"start_url": "./",
|
"start_url": "./",
|
||||||
|
|||||||
195
public/sw.js
195
public/sw.js
@ -130,7 +130,61 @@
|
|||||||
// not only in the moment a location is saved. Previously a reload put the
|
// 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
|
// layer back to hidden and every marker vanished. An empty district still
|
||||||
// opens with the layer off.
|
// 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 SHELL_CACHE = `shell-${CACHE_VERSION}`;
|
||||||
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
|
||||||
const API_CACHE = `api-${CACHE_VERSION}`;
|
const API_CACHE = `api-${CACHE_VERSION}`;
|
||||||
@ -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,
|
// 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).
|
// 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}`;
|
// Tile caches are versioned separately from the app. They used to take their
|
||||||
const TILES_SATELLITE = `tiles-satellite-${CACHE_VERSION}`;
|
// name from CACHE_VERSION, and the activate handler deletes every tiles-* cache
|
||||||
const TILES_CARTO_LIGHT = `tiles-carto-light-${CACHE_VERSION}`;
|
// whose name is not current — so each release silently threw away every tile an
|
||||||
const TILES_CARTO_DARK = `tiles-carto-dark-${CACHE_VERSION}`;
|
// 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.
|
// Per-provider tile limits.
|
||||||
// • OSM and Topographic are the providers offered for active offline
|
// • 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.
|
// 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 = [
|
const SHELL_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
@ -221,36 +295,89 @@ self.addEventListener('install', (event) => {
|
|||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
console.log('[SW] Activating...');
|
console.log('[SW] Activating...');
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil((async () => {
|
||||||
caches.keys()
|
const cacheNames = await caches.keys();
|
||||||
.then((cacheNames) => {
|
|
||||||
// Build the set of caches that should remain
|
|
||||||
const keep = new Set([SHELL_CACHE, MODULES_CACHE, API_CACHE, ...ALL_TILE_CACHES]);
|
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
|
cacheNames
|
||||||
// Delete anything that:
|
.filter((name) =>
|
||||||
// • belongs to one of our managed cache prefixes (shell-, tiles-, modules-, api-)
|
(name.startsWith('shell-') || name.startsWith('modules-') || name.startsWith('api-'))
|
||||||
// • but is NOT in the current keep set
|
&& !keep.has(name))
|
||||||
// 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);
|
|
||||||
})
|
|
||||||
.map((name) => {
|
.map((name) => {
|
||||||
console.log('[SW] Deleting old cache:', name);
|
console.log('[SW] Deleting old cache:', name);
|
||||||
return caches.delete(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
|
// FETCH EVENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -265,11 +392,17 @@ self.addEventListener('fetch', (event) => {
|
|||||||
// Skip chrome-extension and other non-http(s) requests
|
// Skip chrome-extension and other non-http(s) requests
|
||||||
if (!url.protocol.startsWith('http')) return;
|
if (!url.protocol.startsWith('http')) return;
|
||||||
|
|
||||||
// Skip worker files and Vite dev-server node_modules requests —
|
// Skip the Vite dev server's own module plumbing — it serves worker scripts
|
||||||
// intercepting these breaks module workers (e.g. SQLocal/SQLite).
|
// straight out of node_modules with a ?worker_file query, and intercepting
|
||||||
if (url.pathname.includes('node_modules') ||
|
// those breaks module workers (e.g. SQLocal/SQLite).
|
||||||
url.search.includes('worker_file') ||
|
if (url.pathname.includes('node_modules') || url.search.includes('worker_file')) return;
|
||||||
request.destination === 'worker') 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) -----
|
// ----- TILE REQUESTS — passive cache-then-network (per-host bucket) -----
|
||||||
const tileCache = getTileCacheName(url);
|
const tileCache = getTileCacheName(url);
|
||||||
|
|||||||
@ -2,14 +2,15 @@
|
|||||||
* xlsx.js — a minimal, dependency-free XLSX writer.
|
* xlsx.js — a minimal, dependency-free XLSX writer.
|
||||||
*
|
*
|
||||||
* An .xlsx file is a ZIP archive of XML parts. This module builds the few
|
* 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)
|
* parts Excel actually requires and packs them with ../zip-store.js, a small
|
||||||
* ZIP writer — readers accept stored entries, and an analysis table is far too
|
* STORED (uncompressed) ZIP writer — readers accept stored entries, and an
|
||||||
* small for compression to matter.
|
* analysis table is far too small for compression to matter.
|
||||||
*
|
*
|
||||||
* Why not a library: SheetJS's npm package is stale and carries advisories,
|
* Why not a library: SheetJS's npm package is stale and carries advisories, and
|
||||||
* ExcelJS is heavy for an offline-first field app, and the JSZip in node_modules
|
* ExcelJS is heavy for an offline-first field app. This is ~200 lines we
|
||||||
* is only a transitive dependency of shp-write (so it could disappear). This is
|
* control, adds nothing to the dependency tree, and works offline. (The JSZip
|
||||||
* ~200 lines we control, adds nothing to the dependency tree, and works offline.
|
* 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]] }])
|
* buildXlsxBlob([{ name: 'Results', rows: [['Zone','Count'], ['North', 3]] }])
|
||||||
*
|
*
|
||||||
@ -18,6 +19,8 @@
|
|||||||
* as { cells: [...], bold: true } renders that row in bold.
|
* as { cells: [...], bold: true } renders that row in bold.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { zipStore, downloadBlob } from '../zip-store.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// XML helpers
|
// 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>
|
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
|
||||||
</styleSheet>`;
|
</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
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -204,16 +134,8 @@ ${sheetEntries.map((s) => `<Relationship Id="rId${s.id}" Type="http://schemas.op
|
|||||||
|
|
||||||
files['xl/styles.xml'] = STYLES_XML;
|
files['xl/styles.xml'] = STYLES_XML;
|
||||||
|
|
||||||
return zipStore(files);
|
return zipStore(files,
|
||||||
}
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
|
||||||
/** 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convenience: build and download in one call. */
|
/** Convenience: build and download in one call. */
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { getLength, getArea } from 'ol/sphere';
|
|||||||
import Feature from 'ol/Feature';
|
import Feature from 'ol/Feature';
|
||||||
import { unByKey } from 'ol/Observable';
|
import { unByKey } from 'ol/Observable';
|
||||||
import { formatLength, formatArea, formatCircleExtent } from '../units.js';
|
import { formatLength, formatArea, formatCircleExtent } from '../units.js';
|
||||||
|
import { groundFromProjected, projectedFromGround, radiusStep } from '../units.js';
|
||||||
|
|
||||||
// ol-ext imports
|
// ol-ext imports
|
||||||
import EditBar from 'ol-ext/control/EditBar';
|
import EditBar from 'ol-ext/control/EditBar';
|
||||||
@ -143,6 +144,164 @@ export class MapTools {
|
|||||||
this.map.addOverlay(this.measureTooltip);
|
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">−</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
|
* Remove any active interaction
|
||||||
*/
|
*/
|
||||||
@ -205,7 +364,9 @@ export class MapTools {
|
|||||||
const geom = e.target;
|
const geom = e.target;
|
||||||
|
|
||||||
if (geom instanceof Circle) {
|
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 area = formatCircleExtent(radius);
|
||||||
const radiusFormatted = formatLength(radius);
|
const radiusFormatted = formatLength(radius);
|
||||||
|
|
||||||
@ -240,10 +401,13 @@ export class MapTools {
|
|||||||
});
|
});
|
||||||
radiusLine.set('_layerType', 'measure_circle_radius');
|
radiusLine.set('_layerType', 'measure_circle_radius');
|
||||||
this.measureSource.addFeature(radiusLine);
|
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.measureTooltipElement.className = 'measure-tooltip measure-tooltip-static';
|
||||||
this.measureTooltip.setOffset([0, -7]);
|
this.measureTooltip.setOffset([0, -7]);
|
||||||
|
this._attachRadiusAdjuster(feature, this.measureTooltip, this.measureTooltipElement);
|
||||||
|
|
||||||
// Create new tooltip for next measurement
|
// Create new tooltip for next measurement
|
||||||
this.measureTooltipElement = null;
|
this.measureTooltipElement = null;
|
||||||
|
|||||||
@ -42,6 +42,7 @@ import { getArea, getLength } from 'ol/sphere';
|
|||||||
import { fromCircle } from 'ol/geom/Polygon';
|
import { fromCircle } from 'ol/geom/Polygon';
|
||||||
import ScaleLine from 'ol/control/ScaleLine';
|
import ScaleLine from 'ol/control/ScaleLine';
|
||||||
import { formatLength, formatLengthFull, formatArea, formatAreaFull } from '../units.js';
|
import { formatLength, formatLengthFull, formatArea, formatAreaFull } from '../units.js';
|
||||||
|
import { groundFromProjected } from '../units.js';
|
||||||
|
|
||||||
// ol-ext LayerSwitcher
|
// ol-ext LayerSwitcher
|
||||||
import LayerSwitcher from 'ol-ext/control/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
|
* and every button is given an icon from here instead, which makes this the single
|
||||||
* place the toolbar's iconography is defined.
|
* 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 = {
|
const TOOLBAR_ICONS = {
|
||||||
// ol-ext built-ins
|
// ol-ext built-ins
|
||||||
'ol-selection': 'bi-cursor',
|
'ol-selection': 'bi-cursor',
|
||||||
@ -1670,7 +1700,7 @@ export class MapView {
|
|||||||
this.infoPopup = new Overlay({
|
this.infoPopup = new Overlay({
|
||||||
element: this.infoPopupElement,
|
element: this.infoPopupElement,
|
||||||
positioning: 'bottom-center',
|
positioning: 'bottom-center',
|
||||||
offset: [0, -10],
|
offset: POPUP_BASE_OFFSET.slice(),
|
||||||
stopEvent: true,
|
stopEvent: true,
|
||||||
autoPan: true,
|
autoPan: true,
|
||||||
autoPanAnimation: { duration: 250 },
|
autoPanAnimation: { duration: 250 },
|
||||||
@ -1821,6 +1851,61 @@ export class MapView {
|
|||||||
* @param {Array<{label:string, value:string, color?:string, empty?:boolean}>} dataRows
|
* @param {Array<{label:string, value:string, color?:string, empty?:boolean}>} dataRows
|
||||||
* @returns {string} HTML
|
* @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) {
|
_buildAnalysisPopupHtml(emoji, title, dataRows) {
|
||||||
let tableRows = '';
|
let tableRows = '';
|
||||||
for (const row of dataRows) {
|
for (const row of dataRows) {
|
||||||
@ -1841,7 +1926,7 @@ export class MapView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `
|
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>
|
<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;">×</button>
|
<button id="info-popup-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">×</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1884,6 +1969,8 @@ export class MapView {
|
|||||||
this.hideInfoPopup();
|
this.hideInfoPopup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._makePopupDraggable();
|
||||||
|
|
||||||
// Summary rows, cleaned for the PDF report (HTML stripped, spacers removed).
|
// Summary rows, cleaned for the PDF report (HTML stripped, spacers removed).
|
||||||
// These travel with the export event so the export modal can offer PDF
|
// These travel with the export event so the export modal can offer PDF
|
||||||
// alongside the GIS formats — one "Export" entry point instead of two.
|
// alongside the GIS formats — one "Export" entry point instead of two.
|
||||||
@ -2005,9 +2092,13 @@ export class MapView {
|
|||||||
|
|
||||||
scanGroup(this.overlayGroup, 'Overlays');
|
scanGroup(this.overlayGroup, 'Overlays');
|
||||||
|
|
||||||
// Build structured data rows
|
// Build structured data rows. The geometry stores a projected radius, which
|
||||||
const radiusFormatted = formatLength(radius);
|
// overstates ground distance by 1/cos(latitude) — about 3 m on a 200 m
|
||||||
const areaSqm = Math.PI * radius * radius;
|
// 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 areaFormatted = formatArea(areaSqm);
|
||||||
|
|
||||||
const dataRows = [
|
const dataRows = [
|
||||||
@ -3012,13 +3103,20 @@ export class MapView {
|
|||||||
});
|
});
|
||||||
topoLayer.set('basemapKey', 'topo');
|
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({
|
const cartoLightLayer = new TileLayer({
|
||||||
title: 'Carto Light',
|
title: 'Carto Light',
|
||||||
type: 'base',
|
type: 'base',
|
||||||
zIndex: -100,
|
zIndex: -100,
|
||||||
visible: defaultBasemap === 'carto-light',
|
visible: defaultBasemap === 'carto-light',
|
||||||
source: new XYZ({
|
source: new XYZ({
|
||||||
url: 'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',
|
url: cartoTileUrl('light_all'),
|
||||||
attributions: '© CARTO',
|
attributions: '© CARTO',
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
crossOrigin: 'anonymous',
|
crossOrigin: 'anonymous',
|
||||||
@ -3032,7 +3130,7 @@ export class MapView {
|
|||||||
zIndex: -100,
|
zIndex: -100,
|
||||||
visible: defaultBasemap === 'carto-dark',
|
visible: defaultBasemap === 'carto-dark',
|
||||||
source: new XYZ({
|
source: new XYZ({
|
||||||
url: 'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
url: cartoTileUrl('dark_all'),
|
||||||
attributions: '© CARTO',
|
attributions: '© CARTO',
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
crossOrigin: 'anonymous',
|
crossOrigin: 'anonymous',
|
||||||
|
|||||||
@ -21,7 +21,8 @@
|
|||||||
|
|
||||||
import GeoJSONFormat from 'ol/format/GeoJSON.js';
|
import GeoJSONFormat from 'ol/format/GeoJSON.js';
|
||||||
import KMLFormat from 'ol/format/KML.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_MAP = 'EPSG:3857';
|
||||||
const PROJ_WGS84 = 'EPSG:4326';
|
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.
|
* A shapefile holds one geometry type, so the writer splits the collection
|
||||||
* It splits geometries by type and writes one shapefile per type inside a
|
* into up to three sets inside a single .zip. Attribute names are sanitised
|
||||||
* single .zip download. We pre-sanitise field names to honour DBF's 10-char
|
* to DBF's 10-character limit by the writer itself.
|
||||||
* limit (otherwise shp-write silently truncates, possibly causing collisions).
|
*
|
||||||
|
* 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) {
|
function writeShp(geojson, filenameBase) {
|
||||||
const sanitised = sanitiseShpFieldNames(geojson);
|
const blob = buildShapefileZip(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, {
|
|
||||||
folder: filenameBase,
|
folder: filenameBase,
|
||||||
outputType: 'blob',
|
|
||||||
compression: 'DEFLATE',
|
|
||||||
types: {
|
types: {
|
||||||
point: `${filenameBase}_point`,
|
point: `${filenameBase}_point`,
|
||||||
polygon: `${filenameBase}_polygon`,
|
polygon: `${filenameBase}_polygon`,
|
||||||
polyline: `${filenameBase}_line`,
|
polyline: `${filenameBase}_line`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
resolve();
|
downloadBlob(blob, `${filenameBase}.zip`);
|
||||||
} 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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/pwa.js
51
src/pwa.js
@ -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
|
// Install Prompt
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -467,6 +514,10 @@ export async function initPWA(options = {}) {
|
|||||||
|
|
||||||
if (autoRegisterSW) {
|
if (autoRegisterSW) {
|
||||||
await registerServiceWorker();
|
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);
|
initInstallPrompt(installButton);
|
||||||
|
|||||||
@ -17,7 +17,16 @@
|
|||||||
// Most endpoints live under the /spatial_planning module, but not all — the
|
// Most endpoints live under the /spatial_planning module, but not all — the
|
||||||
// contours endpoint sits in its own module. Endpoint names containing a slash
|
// contours endpoint sits in its own module. Endpoint names containing a slash
|
||||||
// are resolved against API_ROOT instead (see resolveEndpoint below).
|
// 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`;
|
const API_BASE = `${API_ROOT}/spatial_planning`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
529
src/shapefile-write.js
Normal file
529
src/shapefile-write.js
Normal 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);
|
||||||
|
}
|
||||||
49
src/units.js
49
src/units.js
@ -121,3 +121,52 @@ export function formatAreaFull(sqMetres) {
|
|||||||
export function formatCircleExtent(radiusMetres) {
|
export function formatCircleExtent(radiusMetres) {
|
||||||
return formatArea(Math.PI * radiusMetres * 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
112
src/zip-store.js
Normal 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);
|
||||||
|
}
|
||||||
@ -16,6 +16,18 @@ export default defineConfig({
|
|||||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
'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
|
// 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
|
// Without its own chunk, Rollup assigns it to one of them and the two
|
||||||
// chunks reference each other ("Circular chunk: jspdf -> geotiff").
|
// chunks reference each other ("Circular chunk: jspdf -> geotiff").
|
||||||
if (id.includes('node_modules/pako/')) return 'pako';
|
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
|
// Turf.js — only needed when the Analysis panel is opened, which
|
||||||
// main.js imports dynamically. Keep it out of the eager bundle.
|
// main.js imports dynamically. Keep it out of the eager bundle.
|
||||||
if (id.includes('node_modules/@turf/')) return 'turf';
|
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/')) return 'openlayers';
|
||||||
if (id.includes('node_modules/ol-ext/')) return 'ol-ext';
|
if (id.includes('node_modules/ol-ext/')) return 'ol-ext';
|
||||||
if (id.includes('node_modules/bootstrap/')) return 'bootstrap';
|
if (id.includes('node_modules/bootstrap/')) return 'bootstrap';
|
||||||
// shpjs (+ its jszip dependency) — dynamically imported at runtime,
|
// shpjs — dynamically imported at runtime (main.js), so this chunk is
|
||||||
// so this chunk is only fetched when the user imports a shapefile.
|
// only fetched when the user imports a shapefile. Writing shapefiles
|
||||||
if (id.includes('node_modules/shpjs/') || id.includes('node_modules/jszip/')) return 'shpjs';
|
// 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';
|
if (id.includes('node_modules/jspdf')) return 'jspdf';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user