Merge circle-radius-entry: typed circle radius and movable analysis popup
Brings in the radius adjuster on a finished circle's tooltip, the ground-distance correction for circle measurements, the draggable Circle and Area analysis popups, and the two fixes found by testing them through their own controls. Kept on a branch while it waited for a usability check. LUSPA approved it on a desktop; the touch behaviour on a phone is still unverified, because the remote server was down, and remains an open item. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
f4c56e5078
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
dist/assets/index-D5My3hAw.js.map
vendored
Normal file
1
dist/assets/index-D5My3hAw.js.map
vendored
Normal file
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
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
@ -1,2 +1,2 @@
|
|||||||
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};
|
import{E as f,a as b}from"./jspdf-BsOD2LDR.js";import"./pako-Xa-UToif.js";import"./geotiff-B9aROX49.js";import"./openlayers-D5-VSKzq.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
|
//# sourceMappingURL=pdf-export-C0eAmANf.js.map
|
||||||
File diff suppressed because one or more lines are too long
88
dist/index.html
vendored
88
dist/index.html
vendored
@ -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: '';
|
||||||
@ -1917,12 +1997,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/index-ErUYnuzo.js"></script>
|
<script type="module" crossorigin src="/assets/index-D5My3hAw.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/openlayers-J9qS6Th1.js">
|
<link rel="modulepreload" crossorigin href="/assets/openlayers-D5-VSKzq.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/pako-Xa-UToif.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/geotiff-B9aROX49.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/ol-ext-DV70ljNY.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/shpjs-Doisr2ug.js">
|
<link rel="modulepreload" crossorigin href="/assets/shpjs-Doisr2ug.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/openlayers-BtPuoxOl.css">
|
<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">
|
||||||
|
|||||||
80
index.html
80
index.html
@ -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: '';
|
||||||
|
|||||||
@ -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,12 @@ 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.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 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 +1677,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 +1828,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 +1903,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 +1946,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 +2069,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 = [
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user