Create new project design plans
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
// Boundary Tracer: hand-click a polygon boundary while looking at a
|
||||
// reference map in another tab, save it as GeoJSON for map-checker's
|
||||
// point-in-polygon analysis. Tracing itself never fetches from any
|
||||
// third-party site; the optional overlay layer is the one exception and
|
||||
// only ever fetches a URL the user explicitly typed in.
|
||||
|
||||
const POLYGON_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'];
|
||||
|
||||
let map;
|
||||
let polygons = []; // { id, name, latlngs: [[lat,lng],...], layer: L.Polygon }
|
||||
let nextPolygonId = 1;
|
||||
|
||||
let drawingActive = false;
|
||||
let drawingPoints = []; // [[lat,lng],...]
|
||||
let drawingLayer = null; // preview L.Polyline/L.Polygon
|
||||
let vertexMarkers = [];
|
||||
|
||||
// Overlay layer: the user pastes either a standard {z}/{x}/{y} tile URL, or
|
||||
// the base URL of an ArcGIS tiled MapServer. Both are fetched directly by
|
||||
// the browser from whatever URL the user gave - never proxied server-side.
|
||||
//
|
||||
// A generic {z}/{x}/{y} template is assumed to already follow the standard
|
||||
// Web Mercator slippy-tile grid and is rendered with a plain L.tileLayer.
|
||||
//
|
||||
// An ArcGIS MapServer may instead publish tiles on its own scheme (see that
|
||||
// service's own `?f=json` -> tileInfo: origin, per-level resolution, tile
|
||||
// size). Many such caches - including local government ones - use a plain
|
||||
// geographic (WGS84, wkid 4326) grid rather than Web Mercator, which a
|
||||
// standard tile client can't address with z/x/y math. We handle that case by
|
||||
// computing each source tile's true lat/lng box from tileInfo and dropping
|
||||
// it in as an L.imageOverlay, which Leaflet projects correctly onto the
|
||||
// Mercator base map regardless of the source's own projection.
|
||||
let overlayMode = null; // 'xyz' | 'arcgis-geo'
|
||||
let overlayTileLayer = null; // 'xyz' mode: L.tileLayer
|
||||
let overlayGroup = null; // 'arcgis-geo' mode: L.layerGroup of L.imageOverlay
|
||||
let overlayArcGisMeta = null; // 'arcgis-geo' mode: { baseUrl, tileInfo, opacity }
|
||||
const OVERLAY_STORAGE_KEY = 'boundary-tracer-overlay';
|
||||
const MAX_ARCGIS_OVERLAY_TILES = 80;
|
||||
|
||||
function initMap() {
|
||||
map = initBaseMap('map', [28.7, -81.7], 10);
|
||||
map.on('click', onMapClick);
|
||||
}
|
||||
|
||||
function isArcGisMapServerUrl(url) {
|
||||
return /\/MapServer\/?$/i.test(url);
|
||||
}
|
||||
|
||||
function addOverlay(url, opacity) {
|
||||
removeOverlay();
|
||||
url = url.trim();
|
||||
if (isArcGisMapServerUrl(url)) {
|
||||
addArcGisGeoOverlay(url, opacity);
|
||||
} else {
|
||||
overlayMode = 'xyz';
|
||||
overlayTileLayer = L.tileLayer(url, { opacity, maxZoom: 22, maxNativeZoom: 20 }).addTo(map);
|
||||
document.getElementById('removeOverlayBtn').disabled = false;
|
||||
document.getElementById('overlayOpacityRow').style.display = 'flex';
|
||||
}
|
||||
try { localStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify({ url, opacity })); } catch (e) {}
|
||||
}
|
||||
|
||||
function addArcGisGeoOverlay(baseUrl, opacity) {
|
||||
const cleanUrl = baseUrl.replace(/\/$/, '');
|
||||
fetch(cleanUrl + '?f=json')
|
||||
.then(r => r.json())
|
||||
.then(meta => {
|
||||
const tileInfo = meta.tileInfo;
|
||||
if (!tileInfo || !tileInfo.lods || !tileInfo.origin) {
|
||||
showStatus('This ArcGIS service has no tileInfo - it may not be a cached/tiled MapServer.', 'error');
|
||||
return;
|
||||
}
|
||||
const wkid = (tileInfo.spatialReference || {}).wkid;
|
||||
if (wkid !== 4326) {
|
||||
showStatus(`Unsupported tile spatial reference (wkid ${wkid}). Only geographic (wkid 4326) ArcGIS tile schemes are supported.`, 'error');
|
||||
return;
|
||||
}
|
||||
overlayMode = 'arcgis-geo';
|
||||
overlayArcGisMeta = { baseUrl: cleanUrl, tileInfo, opacity };
|
||||
overlayGroup = L.layerGroup().addTo(map);
|
||||
map.on('moveend', redrawArcGisOverlay);
|
||||
document.getElementById('removeOverlayBtn').disabled = false;
|
||||
document.getElementById('overlayOpacityRow').style.display = 'flex';
|
||||
redrawArcGisOverlay();
|
||||
showStatus('ArcGIS overlay loaded - it refreshes automatically as you pan/zoom.', 'success');
|
||||
})
|
||||
.catch(err => showStatus(`Failed to load ArcGIS service metadata: ${err.message}`, 'error'));
|
||||
}
|
||||
|
||||
function pickArcGisLevel(tileInfo) {
|
||||
// Match the current Leaflet zoom to the tileInfo LOD with the closest
|
||||
// resolution, using the standard Web Mercator meters-per-pixel formula
|
||||
// converted to degrees. This is an approximation (good enough to pick
|
||||
// the nearest of a handful of power-of-two levels) not an exact reprojection.
|
||||
const zoom = map.getZoom();
|
||||
const metersPerPixel = 156543.03392 * Math.cos(map.getCenter().lat * Math.PI / 180) / Math.pow(2, zoom);
|
||||
const degPerPixel = metersPerPixel / 111320;
|
||||
let best = tileInfo.lods[0];
|
||||
let bestDiff = Infinity;
|
||||
tileInfo.lods.forEach(lod => {
|
||||
const diff = Math.abs(lod.resolution - degPerPixel);
|
||||
if (diff < bestDiff) { bestDiff = diff; best = lod; }
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
function redrawArcGisOverlay() {
|
||||
if (!overlayGroup || !overlayArcGisMeta) return;
|
||||
overlayGroup.clearLayers();
|
||||
const { baseUrl, tileInfo, opacity } = overlayArcGisMeta;
|
||||
const lod = pickArcGisLevel(tileInfo);
|
||||
const tileSizeDeg = lod.resolution * tileInfo.cols;
|
||||
const originX = tileInfo.origin.x, originY = tileInfo.origin.y;
|
||||
const bounds = map.getBounds();
|
||||
|
||||
const colMin = Math.floor((bounds.getWest() - originX) / tileSizeDeg);
|
||||
const colMax = Math.floor((bounds.getEast() - originX) / tileSizeDeg);
|
||||
const rowMin = Math.floor((originY - bounds.getNorth()) / tileSizeDeg);
|
||||
const rowMax = Math.floor((originY - bounds.getSouth()) / tileSizeDeg);
|
||||
|
||||
const tileCount = (colMax - colMin + 1) * (rowMax - rowMin + 1);
|
||||
if (tileCount > MAX_ARCGIS_OVERLAY_TILES) {
|
||||
showStatus(`Overlay area too large at this zoom (${tileCount} tiles needed) - zoom in further.`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
for (let row = rowMin; row <= rowMax; row++) {
|
||||
for (let col = colMin; col <= colMax; col++) {
|
||||
if (row < 0 || col < 0) continue;
|
||||
const tileNorth = originY - row * tileSizeDeg;
|
||||
const tileSouth = originY - (row + 1) * tileSizeDeg;
|
||||
const tileWest = originX + col * tileSizeDeg;
|
||||
const tileEast = originX + (col + 1) * tileSizeDeg;
|
||||
const url = `${baseUrl}/tile/${lod.level}/${row}/${col}`;
|
||||
L.imageOverlay(url, [[tileSouth, tileWest], [tileNorth, tileEast]], { opacity }).addTo(overlayGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeOverlay() {
|
||||
if (overlayTileLayer) { map.removeLayer(overlayTileLayer); overlayTileLayer = null; }
|
||||
if (overlayGroup) { map.removeLayer(overlayGroup); overlayGroup = null; }
|
||||
map.off('moveend', redrawArcGisOverlay);
|
||||
overlayArcGisMeta = null;
|
||||
overlayMode = null;
|
||||
document.getElementById('removeOverlayBtn').disabled = true;
|
||||
document.getElementById('overlayOpacityRow').style.display = 'none';
|
||||
try { localStorage.removeItem(OVERLAY_STORAGE_KEY); } catch (e) {}
|
||||
}
|
||||
|
||||
function setOverlayOpacity(opacity) {
|
||||
if (overlayMode === 'xyz' && overlayTileLayer) {
|
||||
overlayTileLayer.setOpacity(opacity);
|
||||
} else if (overlayMode === 'arcgis-geo' && overlayArcGisMeta) {
|
||||
overlayArcGisMeta.opacity = opacity;
|
||||
redrawArcGisOverlay();
|
||||
}
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(OVERLAY_STORAGE_KEY) || 'null');
|
||||
if (saved) { saved.opacity = opacity; localStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(saved)); }
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function restoreOverlayFromStorage() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(OVERLAY_STORAGE_KEY) || 'null');
|
||||
if (saved && saved.url) {
|
||||
document.getElementById('overlayUrlInput').value = saved.url;
|
||||
document.getElementById('overlayOpacitySlider').value = Math.round((saved.opacity ?? 0.7) * 100);
|
||||
addOverlay(saved.url, saved.opacity ?? 0.7);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function colorForIndex(i) {
|
||||
return POLYGON_COLORS[i % POLYGON_COLORS.length];
|
||||
}
|
||||
|
||||
function onMapClick(e) {
|
||||
if (!drawingActive) return;
|
||||
drawingPoints.push([e.latlng.lat, e.latlng.lng]);
|
||||
redrawPreview();
|
||||
updateDrawButtons();
|
||||
saveDraft();
|
||||
}
|
||||
|
||||
function redrawPreview() {
|
||||
if (drawingLayer) {
|
||||
map.removeLayer(drawingLayer);
|
||||
drawingLayer = null;
|
||||
}
|
||||
vertexMarkers.forEach(m => map.removeLayer(m));
|
||||
vertexMarkers = [];
|
||||
|
||||
if (drawingPoints.length === 0) return;
|
||||
|
||||
if (drawingPoints.length >= 3) {
|
||||
drawingLayer = L.polygon(drawingPoints, {
|
||||
color: '#ffc107', weight: 2, fillOpacity: 0.15, dashArray: '5,5'
|
||||
}).addTo(map);
|
||||
} else {
|
||||
drawingLayer = L.polyline(drawingPoints, {
|
||||
color: '#ffc107', weight: 2, dashArray: '5,5'
|
||||
}).addTo(map);
|
||||
}
|
||||
|
||||
drawingPoints.forEach(pt => {
|
||||
const marker = L.circleMarker(pt, {
|
||||
radius: 4, fillColor: '#ffc107', color: '#a67c00', weight: 1, fillOpacity: 1
|
||||
}).addTo(map);
|
||||
vertexMarkers.push(marker);
|
||||
});
|
||||
}
|
||||
|
||||
function updateDrawButtons() {
|
||||
document.getElementById('undoPointBtn').disabled = drawingPoints.length === 0;
|
||||
document.getElementById('finishPolygonBtn').disabled = drawingPoints.length < 3;
|
||||
const hint = document.getElementById('drawHint');
|
||||
if (drawingActive) {
|
||||
hint.textContent = `${drawingPoints.length} point(s) placed. Click the map to add more, then "Finish polygon".`;
|
||||
}
|
||||
}
|
||||
|
||||
function startDrawing() {
|
||||
drawingActive = true;
|
||||
drawingPoints = [];
|
||||
redrawPreview();
|
||||
document.getElementById('newPolygonBtn').disabled = true;
|
||||
document.getElementById('undoPointBtn').disabled = true;
|
||||
document.getElementById('finishPolygonBtn').disabled = true;
|
||||
document.getElementById('cancelPolygonBtn').disabled = false;
|
||||
document.getElementById('drawHint').textContent = 'Click the map to place vertices.';
|
||||
}
|
||||
|
||||
function undoPoint() {
|
||||
if (drawingPoints.length === 0) return;
|
||||
drawingPoints.pop();
|
||||
redrawPreview();
|
||||
updateDrawButtons();
|
||||
saveDraft();
|
||||
}
|
||||
|
||||
function cancelDrawing() {
|
||||
drawingActive = false;
|
||||
drawingPoints = [];
|
||||
if (drawingLayer) { map.removeLayer(drawingLayer); drawingLayer = null; }
|
||||
vertexMarkers.forEach(m => map.removeLayer(m));
|
||||
vertexMarkers = [];
|
||||
document.getElementById('newPolygonBtn').disabled = false;
|
||||
document.getElementById('undoPointBtn').disabled = true;
|
||||
document.getElementById('finishPolygonBtn').disabled = true;
|
||||
document.getElementById('cancelPolygonBtn').disabled = true;
|
||||
document.getElementById('drawHint').textContent = 'Click "New polygon" to start tracing.';
|
||||
saveDraft();
|
||||
}
|
||||
|
||||
function finishDrawing() {
|
||||
if (drawingPoints.length < 3) return;
|
||||
const defaultName = polygons.length > 0 ? polygons[polygons.length - 1].name : 'The Villages';
|
||||
const name = window.prompt('Name for this polygon (e.g. "The Villages"):', defaultName);
|
||||
if (name === null) return; // cancelled prompt - keep drawing state as-is
|
||||
|
||||
const latlngs = drawingPoints.slice();
|
||||
if (drawingLayer) { map.removeLayer(drawingLayer); drawingLayer = null; }
|
||||
vertexMarkers.forEach(m => map.removeLayer(m));
|
||||
vertexMarkers = [];
|
||||
|
||||
addPolygon(name.trim() || 'Unnamed', latlngs);
|
||||
|
||||
drawingActive = false;
|
||||
drawingPoints = [];
|
||||
document.getElementById('newPolygonBtn').disabled = false;
|
||||
document.getElementById('undoPointBtn').disabled = true;
|
||||
document.getElementById('finishPolygonBtn').disabled = true;
|
||||
document.getElementById('cancelPolygonBtn').disabled = true;
|
||||
document.getElementById('drawHint').textContent = 'Click "New polygon" to start tracing.';
|
||||
saveDraft();
|
||||
}
|
||||
|
||||
function addPolygon(name, latlngs) {
|
||||
const id = nextPolygonId++;
|
||||
const color = colorForIndex(polygons.length);
|
||||
const layer = L.polygon(latlngs, { color, weight: 2, fillOpacity: 0.2 }).addTo(map);
|
||||
polygons.push({ id, name, latlngs, layer });
|
||||
renderPolygonList();
|
||||
}
|
||||
|
||||
function deletePolygon(id) {
|
||||
const idx = polygons.findIndex(p => p.id === id);
|
||||
if (idx === -1) return;
|
||||
map.removeLayer(polygons[idx].layer);
|
||||
polygons.splice(idx, 1);
|
||||
// Recolor remaining layers to keep the palette assignment stable-looking
|
||||
polygons.forEach((p, i) => p.layer.setStyle({ color: colorForIndex(i) }));
|
||||
renderPolygonList();
|
||||
saveDraft();
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function renderPolygonList() {
|
||||
const container = document.getElementById('polygonList');
|
||||
if (polygons.length === 0) {
|
||||
container.innerHTML = '<p style="font-size:11px;color:#999;">None yet.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = polygons.map((p, i) => `
|
||||
<div class="polygon-item">
|
||||
<span class="polygon-swatch" style="background:${colorForIndex(i)};"></span>
|
||||
<span class="polygon-name">${escapeHtml(p.name)} (${p.latlngs.length} pts)</span>
|
||||
<button onclick="window.__deletePolygon(${p.id})">Delete</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
window.__deletePolygon = deletePolygon;
|
||||
|
||||
function polygonsToGeoJson() {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: polygons.map(p => ({
|
||||
type: 'Feature',
|
||||
properties: { name: p.name },
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
// GeoJSON is [lng, lat] and rings must close (first == last)
|
||||
coordinates: [[...p.latlngs.map(([lat, lng]) => [lng, lat]),
|
||||
[p.latlngs[0][1], p.latlngs[0][0]]]]
|
||||
}
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function explodeGeometry(geom) {
|
||||
if (geom.type === 'Polygon') return [geom.coordinates];
|
||||
if (geom.type === 'MultiPolygon') return geom.coordinates;
|
||||
return [];
|
||||
}
|
||||
|
||||
function loadExisting() {
|
||||
fetch('/api/map-checker/boundary')
|
||||
.then(r => r.json())
|
||||
.then(geojson => {
|
||||
(geojson.features || []).forEach(feat => {
|
||||
const name = (feat.properties && feat.properties.name) || 'Unnamed';
|
||||
explodeGeometry(feat.geometry).forEach(rings => {
|
||||
const outerRing = rings[0]; // ignore holes
|
||||
const latlngs = outerRing.slice(0, -1).map(([lng, lat]) => [lat, lng]); // drop closing point
|
||||
if (latlngs.length >= 3) addPolygon(name, latlngs);
|
||||
});
|
||||
});
|
||||
if (polygons.length > 0) {
|
||||
const bounds = L.latLngBounds(polygons.flatMap(p => p.latlngs));
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
}
|
||||
})
|
||||
.catch(err => showStatus(`Failed to load existing boundary: ${err.message}`, 'error'));
|
||||
}
|
||||
|
||||
// Autosave: fired after every vertex click, undo, delete, and finish so an
|
||||
// in-progress trace survives a crashed tab or dropped connection without
|
||||
// needing an explicit "Save to server" click.
|
||||
function saveDraft() {
|
||||
fetch('/api/map-checker/draft', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
keepalive: true,
|
||||
body: JSON.stringify({
|
||||
polygons: polygons.map(p => ({ name: p.name, latlngs: p.latlngs })),
|
||||
inProgress: drawingPoints
|
||||
})
|
||||
}).catch(err => showStatus(`Autosave failed: ${err.message}`, 'error'));
|
||||
}
|
||||
|
||||
function loadInitialState() {
|
||||
fetch('/api/map-checker/draft')
|
||||
.then(r => r.json())
|
||||
.then(draft => {
|
||||
const hasDraft = draft && (
|
||||
(draft.polygons && draft.polygons.length > 0) ||
|
||||
(draft.inProgress && draft.inProgress.length > 0)
|
||||
);
|
||||
if (!hasDraft) {
|
||||
loadExisting();
|
||||
return;
|
||||
}
|
||||
(draft.polygons || []).forEach(p => {
|
||||
if (p.latlngs && p.latlngs.length >= 3) addPolygon(p.name || 'Unnamed', p.latlngs);
|
||||
});
|
||||
if (draft.inProgress && draft.inProgress.length > 0) {
|
||||
drawingActive = true;
|
||||
drawingPoints = draft.inProgress.slice();
|
||||
redrawPreview();
|
||||
document.getElementById('newPolygonBtn').disabled = true;
|
||||
document.getElementById('cancelPolygonBtn').disabled = false;
|
||||
updateDrawButtons();
|
||||
}
|
||||
const allPts = polygons.flatMap(p => p.latlngs).concat(drawingPoints);
|
||||
if (allPts.length > 0) {
|
||||
map.fitBounds(L.latLngBounds(allPts), { padding: [50, 50] });
|
||||
}
|
||||
showStatus('Restored autosaved work from your last session.', 'success');
|
||||
})
|
||||
.catch(err => showStatus(`Failed to load autosaved draft: ${err.message}`, 'error'));
|
||||
}
|
||||
|
||||
function showStatus(msg, type) {
|
||||
const el = document.getElementById('status');
|
||||
el.textContent = msg;
|
||||
el.className = `status ${type}`;
|
||||
}
|
||||
|
||||
function saveToServer() {
|
||||
if (polygons.length === 0) {
|
||||
showStatus('No polygons to save yet.', 'error');
|
||||
return;
|
||||
}
|
||||
fetch('/api/map-checker/boundary', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(polygonsToGeoJson())
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) { showStatus(`Error: ${data.error}`, 'error'); return; }
|
||||
showStatus(`Saved ${data.features} polygon(s) to /data/map-checker/boundary.geojson`, 'success');
|
||||
})
|
||||
.catch(err => showStatus(`Error: ${err.message}`, 'error'));
|
||||
}
|
||||
|
||||
function downloadGeoJson() {
|
||||
if (polygons.length === 0) {
|
||||
showStatus('No polygons to download yet.', 'error');
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(polygonsToGeoJson(), null, 2)], { type: 'application/geo+json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'boundary.geojson';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initMap();
|
||||
initMobileControls();
|
||||
loadInitialState();
|
||||
restoreOverlayFromStorage();
|
||||
|
||||
document.getElementById('newPolygonBtn').addEventListener('click', startDrawing);
|
||||
document.getElementById('undoPointBtn').addEventListener('click', undoPoint);
|
||||
document.getElementById('finishPolygonBtn').addEventListener('click', finishDrawing);
|
||||
document.getElementById('cancelPolygonBtn').addEventListener('click', cancelDrawing);
|
||||
document.getElementById('saveBtn').addEventListener('click', saveToServer);
|
||||
document.getElementById('downloadBtn').addEventListener('click', downloadGeoJson);
|
||||
|
||||
document.getElementById('addOverlayBtn').addEventListener('click', () => {
|
||||
const url = document.getElementById('overlayUrlInput').value.trim();
|
||||
if (!url) { showStatus('Enter a tile URL first.', 'error'); return; }
|
||||
const hasXyzPlaceholders = /\{z\}/.test(url) && /\{x\}/.test(url) && /\{y\}/.test(url);
|
||||
if (!hasXyzPlaceholders && !isArcGisMapServerUrl(url)) {
|
||||
showStatus('URL should contain {z}, {x} and {y} placeholders, or be an ArcGIS .../MapServer base URL.', 'error');
|
||||
return;
|
||||
}
|
||||
const opacity = document.getElementById('overlayOpacitySlider').value / 100;
|
||||
addOverlay(url, opacity);
|
||||
});
|
||||
document.getElementById('removeOverlayBtn').addEventListener('click', removeOverlay);
|
||||
document.getElementById('overlayOpacitySlider').addEventListener('input', (e) => {
|
||||
setOverlayOpacity(e.target.value / 100);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user