Adjust a measured circle's radius from its tooltip, in ground metres
Officers asked to enter an exact distance rather than drag for it. The tooltip
that appears when the second click lands now carries the adjustment: minus, the
figure itself, plus. Tapping the figure swaps in a numeric input, so an exact
radius can be typed; inputmode="decimal" brings up a keypad rather than a full
keyboard on a phone.
Not a dial. A radius runs from a few metres to several kilometres and no dial
gives usable resolution across that range on a tooltip this size. Stepping snaps
to an increment that grows with the value — 1 m below 50, 5 m below 200, 10 m
below 1 km, 50 m below 5 km — so one tap means something at either end, and
repeated taps land on round numbers instead of carrying the fraction the drag
happened to finish on.
Everything writes through MapTools.setCircleRadius(). Measurements were made
read-only earlier precisely because a draggable handle moved the geometry while
the popup went on reporting the radius captured at draw time; a single path
means the geometry, the radius line, the tooltip and the analysis popup cannot
drift apart again.
The figures are now ground distances. The geometry stores a Web Mercator radius,
which overstates the ground by 1/cos(latitude) — a 200 m circle at Wa was 197 m
on the ground. That was invisible while dragging, because the label and the
shape agreed with each other, but typing 200 is a claim about the ground. The
conversion lives in units.js and is applied in the live tooltip, the adjuster
and the Circle Analysis popup, so all three state the same distance.
Two deliberate choices worth knowing before the usability check:
- The tooltip is frozen where the draw finished. It is anchored to the
circumference, so re-positioning on every change would slide the buttons out
from under the officer's thumb. A large adjustment therefore leaves it
sitting inside the circle rather than on its edge.
- No measure-complete event fires on adjustment. Consumers read that as "a new
measurement was drawn", and stepping would emit a stream of false ones.
The overlay is created with stopEvent:false so the map keeps working through the
label; the buttons stop their own events, or a tap would also pan the map.
Service Worker version deliberately unchanged, so reverting does not leave
clients on a newer cache than main.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9b4c7625d2
commit
631b316118
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-C-By5vWJ.js.map
vendored
Normal file
1
dist/assets/index-C-By5vWJ.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-C-By5vWJ.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,152 @@ 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();
|
||||||
|
return groundFromProjected(geom.getRadius(), geom.getCenter());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
|
||||||
|
const commit = (apply) => {
|
||||||
|
if (!input.isConnected) return;
|
||||||
|
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 +352,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 +389,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';
|
||||||
@ -2005,9 +2006,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