Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 42 additions & 84 deletions frontend/src/components/sandboxEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export default function SandboxEditor({
gpxUrl,
projectId,
taskId,
showOsmFeatures,
osmLayerOpacity,
}) {
const dispatch = useDispatch();
const navigate = useNavigate();
Expand All @@ -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);

Expand Down Expand Up @@ -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',
})),
];

Expand All @@ -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 = `
<svg width="16" height="16" viewBox="0 0 8 8">
<path fill="currentColor" d="M4.03 0c-2.53 0-4.03 3-4.03 3s1.5 3 4.03 3c2.47 0 3.97-3 3.97-3s-1.5-3-3.97-3zm-.03 1c1.11 0 2 .9 2 2 0 1.11-.89 2-2 2-1.1 0-2-.89-2-2 0-1.1.9-2 2-2zm0 1c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1c0-.1-.04-.19-.06-.28-.08.16-.24.28-.44.28-.28 0-.5-.22-.5-.5 0-.2.12-.36.28-.44-.09-.03-.18-.06-.28-.06z" transform="translate(0 1)"></path>
</svg>
`;
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]);

Expand Down
125 changes: 125 additions & 0 deletions frontend/src/components/taskSelection/OsmDataControls.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className="ba b--light-gray br2 mt3 mb3 pa3 bg-washed-blue flex justify-center items-center">
<LoadingIcon className="blue-dark h1 w1 mr2" style={{ animation: 'spin 1s linear infinite' }} />
<span className="f7 fw5 blue-grey animate-pulse">
<FormattedMessage {...messages.osmDataControlsFetching} />
</span>
</div>
);
}

// 2. Error State
if (showOsmFeatures && isError) {
return (
<div className="ba b--red br2 mt3 mb3 pa3 bg-washed-red flex justify-between items-center">
<div>
<h4 className="f6 fw6 red mt0 mb1">
<FormattedMessage {...messages.osmDataControlsErrorTitle} />
</h4>
<p className="f7 gray mt0 mb0">
<FormattedMessage {...messages.osmDataControlsErrorDescription} />
</p>
</div>
<button
className="bg-red white f7 fw6 ph3 pv2 br2 bn pointer dim"
onClick={() => setShowOsmFeatures(false)}
>
<FormattedMessage {...messages.osmDataControlsReset} />
</button>
</div>
);
}

// 3. Loaded/Fetched State (Active)
if (showOsmFeatures && isSuccess) {
return (
<div className="ba b--light-gray br2 mt3 mb3">
<div className="flex items-center justify-between pa2 bb b--light-gray bg-washed-blue">
<span className="f6 fw7 ttu blue-grey tracked-tight">
<FormattedMessage {...messages.osmDataControlsTitle} />
</span>
<button
className="pa1 br2 bn pointer flex items-center justify-center bg-blue-dark white dim"
style={{ width: '28px', height: '28px' }}
onClick={() => setShowOsmFeatures(false)}
title={intl.formatMessage(messages.osmDataControlsHideTitle)}
>
<EyeIcon style={{ width: '14px', height: '14px' }} />
</button>
</div>
<div className="pa2">
<p className="f7 ttu blue-grey tracked-tight mt0 mb2">
<FormattedMessage {...messages.osmDataControlsOpacityDescription} />
</p>
<div className="flex items-center">
<span className="f7 gray nowrap mr2">
<FormattedMessage {...messages.osmDataControlsOpacityHidden} />
</span>
<input
type="range"
min="0"
max="100"
value={Math.round(osmLayerOpacity * 100)}
onChange={(e) => setOsmLayerOpacity(e.target.value / 100)}
className="flex-auto"
style={{ cursor: 'pointer' }}
/>
<span className="f7 gray nowrap ml2">
<FormattedMessage {...messages.osmDataControlsOpacitySolid} />
</span>
</div>
</div>
</div>
);
}

// 4. Initial / Hidden State
return (
<div className="ba b--light-gray br2 mt3 mb3 pa3 flex justify-between items-center bg-near-white">
<div className="pr2">
<h4 className="f6 fw6 blue-grey mt0 mb1">
<FormattedMessage {...messages.osmDataControlsLayerTitle} />
</h4>
<p className="f7 gray mt0 mb0">
{isSuccess ? (
<FormattedMessage {...messages.osmDataControlsLayerDescriptionReady} />
) : (
<FormattedMessage {...messages.osmDataControlsLayerDescriptionInitial} />
)}
</p>
</div>
<button
className="bg-blue-dark white f6 fw6 ph3 pv2 br2 bn pointer dim shrink-0"
onClick={() => setShowOsmFeatures(true)}
title={
isSuccess
? intl.formatMessage(messages.osmDataControlsShowTitle)
: intl.formatMessage(messages.osmDataControlsLoadButton)
}
>
{isSuccess ? (
<FormattedMessage {...messages.osmDataControlsShowButton} />
) : (
<FormattedMessage {...messages.osmDataControlsLoadButton} />
)}
</button>
</div>
);
}
23 changes: 22 additions & 1 deletion frontend/src/components/taskSelection/action.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -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);
Expand All @@ -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)
Expand Down Expand Up @@ -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}
/>
) : (
<div>Rapid sandbox editor is under developemnt</div>
Expand Down Expand Up @@ -461,6 +471,17 @@ export function TaskMapAction({ project, tasks, activeTasks, getTasks, action, e
)}
{activeSection === 'instructions' && (
<>
{project.sandbox && (
<OsmDataControls
showOsmFeatures={showOsmFeatures}
setShowOsmFeatures={setShowOsmFeatures}
osmLayerOpacity={osmLayerOpacity}
setOsmLayerOpacity={setOsmLayerOpacity}
isFetching={isOsmFetching}
isSuccess={isOsmSuccess}
isError={isOsmError}
/>
)}
<ProjectInstructions
instructions={project.projectInfo && project.projectInfo.instructions}
/>
Expand Down
Loading
Loading