From 359a2b867f3561ad327d4a63890fb4052b210f65 Mon Sep 17 00:00:00 2001 From: Sumit Dahal Date: Wed, 27 May 2026 13:26:53 +0545 Subject: [PATCH 1/3] feat(sandbox-editor): optimize OSM features opacity controls and layout flow --- frontend/src/components/sandboxEditor.js | 126 ++++++------------ .../taskSelection/OsmDataControls.js | 125 +++++++++++++++++ .../src/components/taskSelection/action.js | 23 +++- .../src/components/taskSelection/messages.js | 60 +++++++++ 4 files changed, 249 insertions(+), 85 deletions(-) create mode 100644 frontend/src/components/taskSelection/OsmDataControls.js diff --git a/frontend/src/components/sandboxEditor.js b/frontend/src/components/sandboxEditor.js index 2f0c862687..a5ba753532 100644 --- a/frontend/src/components/sandboxEditor.js +++ b/frontend/src/components/sandboxEditor.js @@ -25,6 +25,8 @@ export default function SandboxEditor({ gpxUrl, projectId, taskId, + showOsmFeatures, + osmLayerOpacity, }) { const dispatch = useDispatch(); const navigate = useNavigate(); @@ -38,9 +40,12 @@ export default function SandboxEditor({ const [customImageryIsSet, setCustomImageryIsSet] = useState(false); const [isInitialized, setIsInitialized] = useState(false); const [gpxGeojson, setGpxGeojson] = useState(null); - const [showOsmFeatures, setShowOsmFeatures] = useState(false); - const { data: osmFeatures, isError: osmFeaturesError } = useOsmFeaturesQuery(projectId, taskId, !!sandboxId); + const { data: osmFeatures } = useOsmFeaturesQuery( + projectId, + taskId, + !!sandboxId && showOsmFeatures, + ); useSandboxOAuthCallback(sandboxId); @@ -196,12 +201,17 @@ export default function SandboxEditor({ if (isInitialized && iDContext && (osmFeatures || gpxGeojson)) { // Assign stable IDs to ensure iD can maintain hover/select states const features = [ - ...(gpxGeojson?.features || []).map((f, i) => ({ ...f, id: f.id || `gpx-${i}` })), - ...(showOsmFeatures && osmFeatures?.features ? osmFeatures.features : []).map((f) => ({ + ...(gpxGeojson?.features || []).map((f, i) => ({ + ...f, + id: f.id || `gpx-${i}`, + __layerID__: 'gpx-features', + })), + ...(showOsmFeatures && osmFeatures?.features ? osmFeatures.features : []).map((f, i) => ({ ...f, id: f.id || - (f.properties?.osm_id ? `osm-${f.properties.osm_id}` : `osm-gen-${Math.random()}`), + (f.properties?.osm_id ? `osm-${f.properties.osm_id}` : `osm-${i}`), + __layerID__: 'osm-features', })), ]; @@ -215,94 +225,42 @@ export default function SandboxEditor({ }, [isInitialized, iDContext, osmFeatures, gpxGeojson, showOsmFeatures]); useEffect(() => { - if (isInitialized && iDContext) { - const injectButton = () => { - if (document.getElementById('osm-features-toggle')) return true; - - // Find the undo button - we want to insert our button inside the same group - const undoButton = - document.querySelector('.undo-button') || - document.querySelector('.undo'); - - if (undoButton) { - const group = undoButton.parentElement; - const button = document.createElement('button'); - button.id = 'osm-features-toggle'; - // Use bar-button base class only — don't inherit undo's disabled state - button.className = 'bar-button'; - button.disabled = false; - button.title = 'Toggle OSM Features Overlay'; - button.style.cssText = ` - display: inline-flex; - align-items: center; - justify-content: center; - cursor: pointer !important; - `; - button.innerHTML = ` - - - - `; - button.onclick = () => setShowOsmFeatures((prev) => !prev); - // Insert before the undo button, inside the same group - group.insertBefore(button, undoButton); - // Apply initial visibility based on current osmFeatures state - const hasData = osmFeatures?.features?.length > 0; - button.style.display = hasData && !osmFeaturesError ? 'inline-flex' : 'none'; - return true; - } - return false; - }; - - // Try immediately - if (!injectButton()) { - // If not found, check every 500ms for up to 10 seconds - let attempts = 0; - const interval = setInterval(() => { - attempts++; - if (injectButton() || attempts > 20) { - clearInterval(interval); - } - }, 500); - return () => clearInterval(interval); + if (!isInitialized || !iDContext) return; + const container = document.getElementById('id-container'); + if (container) { + const dataLayer = container.querySelector('.layer-data'); + if (dataLayer) { + dataLayer.style.opacity = ''; } } - }, [isInitialized, iDContext, osmFeatures, osmFeaturesError]); - useEffect(() => { - const btn = document.getElementById('osm-features-toggle'); - if (btn) { - if (showOsmFeatures) { - btn.dataset.active = 'true'; - btn.style.background = '#11120B'; - btn.style.color = '#fff'; - btn.style.borderColor = '#11120B'; - } else { - btn.dataset.active = 'false'; - btn.style.background = '#fff'; - btn.style.color = '#3d3d3d'; - btn.style.borderColor = '#d8dae4'; - } + let styleEl = document.getElementById('osm-layer-opacity-style'); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = 'osm-layer-opacity-style'; + document.head.appendChild(styleEl); } - }, [showOsmFeatures]); - useEffect(() => { - const btn = document.getElementById('osm-features-toggle'); - if (!btn) return; - const hasData = osmFeatures?.features?.length > 0; - if (!hasData || osmFeaturesError) { - btn.style.display = 'none'; - } else { - btn.style.display = 'inline-flex'; - } - }, [osmFeatures, osmFeaturesError]); + styleEl.textContent = ` + #id-container .layer-data .osm-features { + opacity: ${osmLayerOpacity ?? 1} !important; + } + #id-container .layer-data .gpx-features { + opacity: 1 !important; + } + `; + + return () => { + const el = document.getElementById('osm-layer-opacity-style'); + if (el) { + el.remove(); + } + }; + }, [isInitialized, iDContext, osmLayerOpacity]); useEffect(() => { return () => { dispatch(setSandboxAuthStatus(sandboxId, 'idle')); - // Clean up injected button to prevent stale closures on re-mount - const btn = document.getElementById('osm-features-toggle'); - if (btn) btn.remove(); }; }, [dispatch, sandboxId]); diff --git a/frontend/src/components/taskSelection/OsmDataControls.js b/frontend/src/components/taskSelection/OsmDataControls.js new file mode 100644 index 0000000000..156e259562 --- /dev/null +++ b/frontend/src/components/taskSelection/OsmDataControls.js @@ -0,0 +1,125 @@ +import { FormattedMessage, useIntl } from 'react-intl'; +import messages from './messages'; +import { EyeIcon, LoadingIcon } from '../svgIcons'; + +export function OsmDataControls({ + showOsmFeatures, + setShowOsmFeatures, + osmLayerOpacity, + setOsmLayerOpacity, + isFetching, + isSuccess, + isError, +}) { + const intl = useIntl(); + + // 1. Fetching State (only show full screen loader when we don't have success cached data yet) + if (showOsmFeatures && isFetching && !isSuccess) { + return ( +
+ + + + +
+ ); + } + + // 2. Error State + if (showOsmFeatures && isError) { + return ( +
+
+

+ +

+

+ +

+
+ +
+ ); + } + + // 3. Loaded/Fetched State (Active) + if (showOsmFeatures && isSuccess) { + return ( +
+
+ + + + +
+
+

+ +

+
+ + + + setOsmLayerOpacity(e.target.value / 100)} + className="flex-auto" + style={{ cursor: 'pointer' }} + /> + + + +
+
+
+ ); + } + + // 4. Initial / Hidden State + return ( +
+
+

+ +

+

+ {isSuccess ? ( + + ) : ( + + )} +

+
+ +
+ ); +} diff --git a/frontend/src/components/taskSelection/action.js b/frontend/src/components/taskSelection/action.js index 19d9de3bff..7191965a36 100644 --- a/frontend/src/components/taskSelection/action.js +++ b/frontend/src/components/taskSelection/action.js @@ -35,9 +35,10 @@ import { ActionTabsNav } from './actionTabsNav'; import { LockedTaskModalContent } from './lockedTasks'; import { SessionAboutToExpire, SessionExpired } from './extendSession'; import { MappingTypes } from '../mappingTypes'; -import { usePriorityAreasQuery, useTaskDetail } from '../../api/projects'; +import { usePriorityAreasQuery, useTaskDetail, useOsmFeaturesQuery } from '../../api/projects'; import OtherTabInfo from './OtherTabInfo'; import { InfoBox } from '../projectDetail/infoBox'; +import { OsmDataControls } from './OsmDataControls'; const Editor = lazy(() => import('../editor')); const RapidEditor = lazy(() => import('../rapidEditor')); @@ -99,6 +100,8 @@ export function TaskMapAction({ project, tasks, activeTasks, getTasks, action, e const [showMapChangesModal, setShowMapChangesModal] = useState(false); const [showSessionExpiringDialog, setShowSessionExpiringDialog] = useState(false); const [showSessionExpiredDialog, setSessionTimeExpiredDialog] = useState(false); + const [showOsmFeatures, setShowOsmFeatures] = useState(false); + const [osmLayerOpacity, setOsmLayerOpacity] = useState(1); const activeTask = activeTasks?.[0]; const timer = new Date(activeTask.lastUpdated); @@ -108,6 +111,11 @@ export function TaskMapAction({ project, tasks, activeTasks, getTasks, action, e const { data: priorityArea, isError: isPriorityAreaError } = usePriorityAreasQuery( project.projectId, ); + const { + isFetching: isOsmFetching, + isSuccess: isOsmSuccess, + isError: isOsmError, + } = useOsmFeaturesQuery(project.projectId, tasksIds[0], !!project.sandbox && showOsmFeatures); const contributors = taskDetail?.taskHistory ? getTaskContributors(taskDetail.taskHistory, userDetails.username) @@ -254,6 +262,8 @@ export function TaskMapAction({ project, tasks, activeTasks, getTasks, action, e gpxUrl={getTaskGpxUrlCallback(project.projectId, tasksIds)} projectId={project.projectId} taskId={tasksIds[0]} + showOsmFeatures={showOsmFeatures} + osmLayerOpacity={osmLayerOpacity} /> ) : (
Rapid sandbox editor is under developemnt
@@ -461,6 +471,17 @@ export function TaskMapAction({ project, tasks, activeTasks, getTasks, action, e )} {activeSection === 'instructions' && ( <> + {project.sandbox && ( + + )} diff --git a/frontend/src/components/taskSelection/messages.js b/frontend/src/components/taskSelection/messages.js index e38fa23ee1..af7b54f5ec 100644 --- a/frontend/src/components/taskSelection/messages.js +++ b/frontend/src/components/taskSelection/messages.js @@ -898,4 +898,64 @@ export default defineMessages({ id: 'project.tasks.map.invalidationChoropleth.legend.rampHint', defaultMessage: 'Colour blends continuously between stops', }, + osmDataControlsTitle: { + id: 'project.tasks.osm_data_controls.title', + defaultMessage: 'OSM Data Controls', + }, + osmDataControlsOpacityDescription: { + id: 'project.tasks.osm_data_controls.opacity.description', + defaultMessage: 'Adjust OSM data layer opacity', + }, + osmDataControlsOpacityHidden: { + id: 'project.tasks.osm_data_controls.opacity.hidden', + defaultMessage: '0% (Hidden)', + }, + osmDataControlsOpacitySolid: { + id: 'project.tasks.osm_data_controls.opacity.solid', + defaultMessage: '100% (Solid)', + }, + osmDataControlsFetching: { + id: 'project.tasks.osm_data_controls.fetching', + defaultMessage: 'Fetching OSM features...', + }, + osmDataControlsErrorTitle: { + id: 'project.tasks.osm_data_controls.error.title', + defaultMessage: 'Error Loading Features', + }, + osmDataControlsErrorDescription: { + id: 'project.tasks.osm_data_controls.error.description', + defaultMessage: 'Failed to fetch OSM features for this task.', + }, + osmDataControlsReset: { + id: 'project.tasks.osm_data_controls.reset', + defaultMessage: 'Reset', + }, + osmDataControlsLayerTitle: { + id: 'project.tasks.osm_data_controls.layer.title', + defaultMessage: 'OSM Features Layer', + }, + osmDataControlsLayerDescriptionInitial: { + id: 'project.tasks.osm_data_controls.layer.description.initial', + defaultMessage: 'Load and display existing OpenStreetMap features.', + }, + osmDataControlsLayerDescriptionReady: { + id: 'project.tasks.osm_data_controls.layer.description.ready', + defaultMessage: 'OSM features are loaded and ready.', + }, + osmDataControlsLoadButton: { + id: 'project.tasks.osm_data_controls.load.button', + defaultMessage: 'Load OSM Features', + }, + osmDataControlsShowButton: { + id: 'project.tasks.osm_data_controls.show.button', + defaultMessage: 'Show OSM Features', + }, + osmDataControlsHideTitle: { + id: 'project.tasks.osm_data_controls.hide.title', + defaultMessage: 'Hide OSM features', + }, + osmDataControlsShowTitle: { + id: 'project.tasks.osm_data_controls.show.title', + defaultMessage: 'Show OSM features', + }, }); From b2b4704f15890382d44c366e53e571998cc4ab23 Mon Sep 17 00:00:00 2001 From: Sumit Dahal Date: Tue, 26 May 2026 09:58:15 +0545 Subject: [PATCH 2/3] feat: show choropleth layer to task selection map to visualize task invalidation counts --- .../src/components/taskSelection/messages.js | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/frontend/src/components/taskSelection/messages.js b/frontend/src/components/taskSelection/messages.js index af7b54f5ec..97ece33170 100644 --- a/frontend/src/components/taskSelection/messages.js +++ b/frontend/src/components/taskSelection/messages.js @@ -958,4 +958,36 @@ export default defineMessages({ id: 'project.tasks.osm_data_controls.show.title', defaultMessage: 'Show OSM features', }, + invalidationChoropleth: { + id: 'project.tasks.map.invalidationChoropleth', + defaultMessage: 'Legend', + }, + invalidationChoroplethToggle: { + id: 'project.tasks.map.invalidationChoropleth.toggle', + defaultMessage: 'Show invalidation heatmap', + }, + invalidationChoroplethLegendTitle: { + id: 'project.tasks.map.invalidationChoropleth.legend.title', + defaultMessage: 'Invalidation count', + }, + invalidationChoroplethNone: { + id: 'project.tasks.map.invalidationChoropleth.legend.none', + defaultMessage: 'Never invalidated', + }, + invalidationChoroplethLow: { + id: 'project.tasks.map.invalidationChoropleth.legend.low', + defaultMessage: '1–2 times', + }, + invalidationChoroplethMedium: { + id: 'project.tasks.map.invalidationChoropleth.legend.medium', + defaultMessage: '3–5 times', + }, + invalidationChoroplethHigh: { + id: 'project.tasks.map.invalidationChoropleth.legend.high', + defaultMessage: '6+ times', + }, + invalidationChoroplethRampHint: { + id: 'project.tasks.map.invalidationChoropleth.legend.rampHint', + defaultMessage: 'Colour blends continuously between stops', + }, }); From 53ab1773111befaa1753b028e1b1a351916dcc36 Mon Sep 17 00:00:00 2001 From: Sumit Dahal Date: Wed, 27 May 2026 14:35:38 +0545 Subject: [PATCH 3/3] refactor: remove unused invalidation choropleth localization strings --- .../src/components/taskSelection/messages.js | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/frontend/src/components/taskSelection/messages.js b/frontend/src/components/taskSelection/messages.js index 97ece33170..af7b54f5ec 100644 --- a/frontend/src/components/taskSelection/messages.js +++ b/frontend/src/components/taskSelection/messages.js @@ -958,36 +958,4 @@ export default defineMessages({ id: 'project.tasks.osm_data_controls.show.title', defaultMessage: 'Show OSM features', }, - invalidationChoropleth: { - id: 'project.tasks.map.invalidationChoropleth', - defaultMessage: 'Legend', - }, - invalidationChoroplethToggle: { - id: 'project.tasks.map.invalidationChoropleth.toggle', - defaultMessage: 'Show invalidation heatmap', - }, - invalidationChoroplethLegendTitle: { - id: 'project.tasks.map.invalidationChoropleth.legend.title', - defaultMessage: 'Invalidation count', - }, - invalidationChoroplethNone: { - id: 'project.tasks.map.invalidationChoropleth.legend.none', - defaultMessage: 'Never invalidated', - }, - invalidationChoroplethLow: { - id: 'project.tasks.map.invalidationChoropleth.legend.low', - defaultMessage: '1–2 times', - }, - invalidationChoroplethMedium: { - id: 'project.tasks.map.invalidationChoropleth.legend.medium', - defaultMessage: '3–5 times', - }, - invalidationChoroplethHigh: { - id: 'project.tasks.map.invalidationChoropleth.legend.high', - defaultMessage: '6+ times', - }, - invalidationChoroplethRampHint: { - id: 'project.tasks.map.invalidationChoropleth.legend.rampHint', - defaultMessage: 'Colour blends continuously between stops', - }, });