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
6 changes: 6 additions & 0 deletions lang/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,11 @@
"Form.textUpload.promptGeoJSON": "Drop GeoJSON file here or click to select file",
"Form.textUpload.promptJSON": "Drop JSON file here or click to select file",
"Form.textUpload.readonly": "Existing file will be used",
"GeoJSONUploadModal.dropzone.label": "Drop a GeoJSON file here or click to upload",
"GeoJSONUploadModal.error.invalid": "Invalid GeoJSON: {error}",
"GeoJSONUploadModal.error.noPolygons": "No Polygon features found in file",
"GeoJSONUploadModal.header": "Create Virtual Challenge from GeoJSON",
"GeoJSONUploadModal.success.polygonsLoaded": "{count, plural, one {# polygon} other {# polygons}} loaded",
"GlobalActivity.title": "Global Activity",
"Grant.Role.admin": "Admin",
"Grant.Role.read": "Read",
Expand Down Expand Up @@ -1394,6 +1399,7 @@
"TaskClusterMap.controls.search.label": "Search",
"TaskClusterMap.controls.selectAllInView.label": "Select All In View",
"TaskClusterMap.controls.toggleLegend.label": "Toggle Legend",
"TaskClusterMap.controls.uploadGeoJSON.label": "Upload GeoJSON",
"TaskClusterMap.controls.zoomIn.label": "Zoom In",
"TaskClusterMap.controls.zoomOut.label": "Zoom Out",
"TaskClusterMap.message.moveMapToRefresh.label": "Click to show tasks",
Expand Down
9 changes: 9 additions & 0 deletions src/components/ChallengeDetail/ChallengeDetail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import StartVirtualChallenge from "../ChallengePane/StartVirtualChallenge/StartV
import TaskChallengeMarkerContent from "../ChallengePane/TaskChallengeMarkerContent";
import ChallengeProgress from "../ChallengeProgress/ChallengeProgress";
import MapPane from "../EnhancedMap/MapPane/MapPane";
import GeoJSONUploadModal from "../GeoJSONUploadModal/GeoJSONUploadModal";
import WithBrowsedChallenge from "../HOCs/WithBrowsedChallenge/WithBrowsedChallenge";
import WithChallengeTaskClusters from "../HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters";
import WithClusteredTasks from "../HOCs/WithClusteredTasks/WithClusteredTasks";
Expand Down Expand Up @@ -74,6 +75,7 @@ export class ChallengeDetail extends Component {
submittingFlag: false,
pickingProject: false,
selectedClusters: [],
showGeoJSONUploadModal: false,
showMore: false,
hasOverflow: null,
};
Expand Down Expand Up @@ -458,6 +460,7 @@ export class ChallengeDetail extends Component {
resetSelectedClusters={this.resetSelectedClusters}
showClusterLasso
showFitWorld
onGeoJSONUpload={() => this.setState({ showGeoJSONUploadModal: true })}
externalOverlay={virtualChallengeMapOverlay}
{...this.props}
/>
Expand All @@ -468,6 +471,12 @@ export class ChallengeDetail extends Component {
<div className="mr-flex-1">
<MapPane>{map}</MapPane>
</div>
{this.state.showGeoJSONUploadModal && (
<GeoJSONUploadModal
onClose={() => this.setState({ showGeoJSONUploadModal: false })}
{...this.props}
/>
)}
<div className="mr-flex-1">
<div className="mr-h-content mr-overflow-auto">
<div className="mr-max-w-md mr-mx-auto">
Expand Down
239 changes: 239 additions & 0 deletions src/components/GeoJSONUploadModal/GeoJSONUploadModal.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import L from "leaflet";
import PropTypes from "prop-types";
import { Component } from "react";
import Dropzone from "react-dropzone";
import { FormattedMessage, injectIntl } from "react-intl";
import { GeoJSON, MapContainer, TileLayer } from "react-leaflet";
import BusySpinner from "../BusySpinner/BusySpinner";
import External from "../External/External";
import Modal from "../Modal/Modal";
import SvgSymbol from "../SvgSymbol/SvgSymbol";
import messages from "./Messages";

/**
* GeoJSONUploadModal provides a modal with a dropzone for uploading a GeoJSON
* file to create a virtual challenge. It parses polygon features from the file,
* shows a map preview of the polygons, prompts for a challenge name, and
* initiates creation.
*/
class GeoJSONUploadModal extends Component {
state = {
parsedClusters: null,
parsedFeatureCollection: null,
challengeName: "",
error: null,
successMessage: null,
creating: false,
};

handleFileDrop = async (files) => {
if (!files || files.length === 0) return;

const file = files[0];
this.setState({
error: null,
successMessage: null,
parsedClusters: null,
parsedFeatureCollection: null,
});

try {
const text = await file.text();
const geoJson = JSON.parse(text);

let features = [];
if (geoJson.type === "FeatureCollection" && Array.isArray(geoJson.features)) {
features = geoJson.features;
} else if (geoJson.type === "Feature") {
features = [geoJson];
} else if (geoJson.type === "Polygon" || geoJson.type === "MultiPolygon") {
features = [{ type: "Feature", geometry: geoJson, properties: {} }];
} else {
throw new Error("Unsupported GeoJSON type");
}

const polygonFeatures = features.filter(
(f) => f.geometry && (f.geometry.type === "Polygon" || f.geometry.type === "MultiPolygon"),
);

if (polygonFeatures.length === 0) {
this.setState({
error: this.props.intl.formatMessage(messages.noPolygonsFound),
});
return;
}

const clusters = polygonFeatures.map((feature, index) => ({
bounding: feature.geometry,
numberOfPoints: 0,
clusterId: `geojson-${index}`,
}));

const featureCollection = {
type: "FeatureCollection",
features: polygonFeatures,
};

this.setState({
parsedClusters: clusters,
parsedFeatureCollection: featureCollection,
successMessage: this.props.intl.formatMessage(messages.polygonsLoaded, {
count: polygonFeatures.length,
}),
});
} catch (error) {
this.setState({
error: this.props.intl.formatMessage(messages.invalidGeoJSON, {
error: error.message,
}),
});
}
};

handleCreate = () => {
if (!this.state.parsedClusters || !this.state.challengeName.trim()) return;

this.setState({ creating: true });
this.props
.createVirtualChallenge(this.state.challengeName, this.state.parsedClusters)
.catch(() => null)
.then(() => {
this.setState({ creating: false });
this.props.onClose();
});
};

render() {
return (
<External>
<Modal medium isActive onClose={this.props.onClose}>
<div>
<h2 className="mr-text-white mr-text-3xl mr-mb-4">
<FormattedMessage {...messages.header} />
</h2>

{this.state.creating ? (
<BusySpinner />
) : (
<div>
{/* Dropzone */}
<Dropzone
acceptClassName="active"
multiple={false}
disablePreview
onDrop={this.handleFileDrop}
>
{({ getRootProps, getInputProps }) => (
<div
className="dropzone mr-text-green-lighter mr-border-green-lighter mr-border-2 mr-rounded mr-p-6 mr-mx-auto mr-cursor-pointer mr-text-center"
{...getRootProps()}
>
<input {...getInputProps()} />
<SvgSymbol
viewBox="0 0 20 20"
sym="upload-icon"
className="mr-fill-current mr-w-6 mr-h-6 mr-mx-auto mr-mb-2"
/>
<FormattedMessage {...messages.dropzoneLabel} />
</div>
)}
</Dropzone>

{/* Error feedback */}
{this.state.error && (
<div className="mr-text-red-light mr-text-sm mr-mt-2">{this.state.error}</div>
)}

{/* Success feedback */}
{this.state.successMessage && (
<div className="mr-text-green-lighter mr-text-sm mr-mt-2">
{this.state.successMessage}
</div>
)}

{/* Map preview of parsed polygons */}
{this.state.parsedFeatureCollection && (
<div className="mr-mt-4 mr-rounded mr-overflow-hidden mr-border mr-border-white-10">
<MapContainer
center={[0, 0]}
zoom={2}
style={{ height: "300px", width: "100%" }}
attributionControl={false}
whenReady={({ target: map }) => {
// Fit to GeoJSON bounds once map is ready
const geoJSONLayer = L.geoJSON(this.state.parsedFeatureCollection);
const bounds = geoJSONLayer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds.pad(0.1));
}
}}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
/>
<GeoJSON
data={this.state.parsedFeatureCollection}
style={{
color: "#4ade80",
weight: 2,
fillColor: "#4ade80",
fillOpacity: 0.15,
}}
/>
</MapContainer>
</div>
)}

{/* Name input + create button (shown after successful parse) */}
{this.state.parsedClusters && (
<div className="mr-mt-4">
<input
type="text"
className="mr-input mr-w-full mr-mb-4"
placeholder={this.props.intl.formatMessage(messages.nameLabel)}
value={this.state.challengeName}
onChange={(e) => this.setState({ challengeName: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") this.handleCreate();
}}
autoFocus
/>
<div className="mr-flex mr-justify-end mr-items-center mr-gap-4">
<button className="mr-button mr-button--white" onClick={this.props.onClose}>
<FormattedMessage {...messages.cancelLabel} />
</button>
<button
className="mr-button"
onClick={this.handleCreate}
disabled={!this.state.challengeName.trim()}
>
<FormattedMessage {...messages.startLabel} />
</button>
</div>
</div>
)}

{/* Cancel button (shown before parse) */}
{!this.state.parsedClusters && (
<div className="mr-flex mr-justify-end mr-items-center mr-mt-4">
<button className="mr-button mr-button--white" onClick={this.props.onClose}>
<FormattedMessage {...messages.cancelLabel} />
</button>
</div>
)}
</div>
)}
</div>
</Modal>
</External>
);
}
}

GeoJSONUploadModal.propTypes = {
onClose: PropTypes.func.isRequired,
createVirtualChallenge: PropTypes.func.isRequired,
};

export default injectIntl(GeoJSONUploadModal);
43 changes: 43 additions & 0 deletions src/components/GeoJSONUploadModal/Messages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { defineMessages } from "react-intl";

export default defineMessages({
header: {
id: "GeoJSONUploadModal.header",
defaultMessage: "Create Virtual Challenge from GeoJSON",
},

dropzoneLabel: {
id: "GeoJSONUploadModal.dropzone.label",
defaultMessage: "Drop a GeoJSON file here or click to upload",
},

nameLabel: {
id: "VirtualChallenge.fields.name.label",
defaultMessage: 'Name your "virtual" challenge',
},

startLabel: {
id: "Admin.TaskAnalysisTable.controls.startTask.label",
defaultMessage: "Start",
},

cancelLabel: {
id: "Admin.EditProject.controls.cancel.label",
defaultMessage: "Cancel",
},

invalidGeoJSON: {
id: "GeoJSONUploadModal.error.invalid",
defaultMessage: "Invalid GeoJSON: {error}",
},

noPolygonsFound: {
id: "GeoJSONUploadModal.error.noPolygons",
defaultMessage: "No Polygon features found in file",
},

polygonsLoaded: {
id: "GeoJSONUploadModal.success.polygonsLoaded",
defaultMessage: "{count, plural, one {# polygon} other {# polygons}} loaded",
},
});
16 changes: 16 additions & 0 deletions src/components/TaskClusterMap/MapControlsDrawer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,22 @@ const MapControlsDrawer = (props) => {
</div>
)}

{/* GeoJSON Upload Control */}
{props.onGeoJSONUpload && (
<div className="control-group">
<div className="control-item">
<button
className="drawer-control-button"
onClick={props.onGeoJSONUpload}
title={intl.formatMessage(messages.uploadGeoJSONLabel)}
aria-label={intl.formatMessage(messages.uploadGeoJSONLabel)}
>
<SvgSymbol sym="upload-icon" viewBox="0 0 20 20" className="control-icon" />
</button>
</div>
</div>
)}

{/* Lasso Selection Controls */}
{(shouldShowTaskLassoControls || shouldShowClusterLassoControls) && (
<div className="control-group lasso-controls">
Expand Down
4 changes: 4 additions & 0 deletions src/components/TaskClusterMap/Messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,8 @@ export default defineMessages({
id: "PriorityBoundsLayer.priority.unknown",
defaultMessage: "Unknown Priority",
},
uploadGeoJSONLabel: {
id: "TaskClusterMap.controls.uploadGeoJSON.label",
defaultMessage: "Upload GeoJSON",
},
});
Loading