-
-
Save chriswhong/694779bc1f1e5d926e47bab7205fa559 to your computer and use it in GitHub Desktop.
| // custom mapbopx-gl-draw mode that modifies draw_line_string | |
| // shows a center point, radius line, and circle polygon while drawing | |
| // forces draw.create on creation of second vertex | |
| import MapboxDraw from 'mapbox-gl-draw'; | |
| import numeral from 'numeral'; | |
| import lineDistance from 'npm:@turf/line-distance'; | |
| const RadiusMode = MapboxDraw.modes.draw_line_string; | |
| function createVertex(parentId, coordinates, path, selected) { | |
| return { | |
| type: 'Feature', | |
| properties: { | |
| meta: 'vertex', | |
| parent: parentId, | |
| coord_path: path, | |
| active: (selected) ? 'true' : 'false', | |
| }, | |
| geometry: { | |
| type: 'Point', | |
| coordinates, | |
| }, | |
| }; | |
| } | |
| // create a circle-like polygon given a center point and radius | |
| // https://stackoverflow.com/questions/37599561/drawing-a-circle-with-the-radius-in-miles-meters-with-mapbox-gl-js/39006388#39006388 | |
| function createGeoJSONCircle(center, radiusInKm, parentId, points = 64) { | |
| const coords = { | |
| latitude: center[1], | |
| longitude: center[0], | |
| }; | |
| const km = radiusInKm; | |
| const ret = []; | |
| const distanceX = km / (111.320 * Math.cos((coords.latitude * Math.PI) / 180)); | |
| const distanceY = km / 110.574; | |
| let theta; | |
| let x; | |
| let y; | |
| for (let i = 0; i < points; i += 1) { | |
| theta = (i / points) * (2 * Math.PI); | |
| x = distanceX * Math.cos(theta); | |
| y = distanceY * Math.sin(theta); | |
| ret.push([coords.longitude + x, coords.latitude + y]); | |
| } | |
| ret.push(ret[0]); | |
| return { | |
| type: 'Feature', | |
| geometry: { | |
| type: 'Polygon', | |
| coordinates: [ret], | |
| }, | |
| properties: { | |
| parent: parentId, | |
| }, | |
| }; | |
| } | |
| function getDisplayMeasurements(feature) { | |
| // should log both metric and standard display strings for the current drawn feature | |
| // metric calculation | |
| const drawnLength = (lineDistance(feature) * 1000); // meters | |
| let metricUnits = 'm'; | |
| let metricFormat = '0,0'; | |
| let metricMeasurement; | |
| let standardUnits = 'feet'; | |
| let standardFormat = '0,0'; | |
| let standardMeasurement; | |
| metricMeasurement = drawnLength; | |
| if (drawnLength >= 1000) { // if over 1000 meters, upgrade metric | |
| metricMeasurement = drawnLength / 1000; | |
| metricUnits = 'km'; | |
| metricFormat = '0.00'; | |
| } | |
| standardMeasurement = drawnLength * 3.28084; | |
| if (standardMeasurement >= 5280) { // if over 5280 feet, upgrade standard | |
| standardMeasurement /= 5280; | |
| standardUnits = 'mi'; | |
| standardFormat = '0.00'; | |
| } | |
| const displayMeasurements = { | |
| metric: `${numeral(metricMeasurement).format(metricFormat)} ${metricUnits}`, | |
| standard: `${numeral(standardMeasurement).format(standardFormat)} ${standardUnits}`, | |
| }; | |
| return displayMeasurements; | |
| } | |
| const doubleClickZoom = { | |
| enable: (ctx) => { | |
| setTimeout(() => { | |
| // First check we've got a map and some context. | |
| if (!ctx.map || !ctx.map.doubleClickZoom || !ctx._ctx || !ctx._ctx.store || !ctx._ctx.store.getInitialConfigValue) return; | |
| // Now check initial state wasn't false (we leave it disabled if so) | |
| if (!ctx._ctx.store.getInitialConfigValue('doubleClickZoom')) return; | |
| ctx.map.doubleClickZoom.enable(); | |
| }, 0); | |
| }, | |
| }; | |
| RadiusMode.clickAnywhere = function(state, e) { | |
| // this ends the drawing after the user creates a second point, triggering this.onStop | |
| if (state.currentVertexPosition === 1) { | |
| state.line.addCoordinate(0, e.lngLat.lng, e.lngLat.lat); | |
| return this.changeMode('simple_select', { featureIds: [state.line.id] }); | |
| } | |
| this.updateUIClasses({ mouse: 'add' }); | |
| state.line.updateCoordinate(state.currentVertexPosition, e.lngLat.lng, e.lngLat.lat); | |
| if (state.direction === 'forward') { | |
| state.currentVertexPosition += 1; // eslint-disable-line | |
| state.line.updateCoordinate(state.currentVertexPosition, e.lngLat.lng, e.lngLat.lat); | |
| } else { | |
| state.line.addCoordinate(0, e.lngLat.lng, e.lngLat.lat); | |
| } | |
| return null; | |
| }; | |
| // creates the final geojson point feature with a radius property | |
| // triggers draw.create | |
| RadiusMode.onStop = function(state) { | |
| doubleClickZoom.enable(this); | |
| this.activateUIButton(); | |
| // check to see if we've deleted this feature | |
| if (this.getFeature(state.line.id) === undefined) return; | |
| // remove last added coordinate | |
| state.line.removeCoordinate('0'); | |
| if (state.line.isValid()) { | |
| const lineGeoJson = state.line.toGeoJSON(); | |
| // reconfigure the geojson line into a geojson point with a radius property | |
| const pointWithRadius = { | |
| type: 'Feature', | |
| geometry: { | |
| type: 'Point', | |
| coordinates: lineGeoJson.geometry.coordinates[0], | |
| }, | |
| properties: { | |
| radius: (lineDistance(lineGeoJson) * 1000).toFixed(1), | |
| }, | |
| }; | |
| this.map.fire('draw.create', { | |
| features: [pointWithRadius], | |
| }); | |
| } else { | |
| this.deleteFeature([state.line.id], { silent: true }); | |
| this.changeMode('simple_select', {}, { silent: true }); | |
| } | |
| }; | |
| RadiusMode.toDisplayFeatures = function(state, geojson, display) { | |
| const isActiveLine = geojson.properties.id === state.line.id; | |
| geojson.properties.active = (isActiveLine) ? 'true' : 'false'; | |
| if (!isActiveLine) return display(geojson); | |
| // Only render the line if it has at least one real coordinate | |
| if (geojson.geometry.coordinates.length < 2) return null; | |
| geojson.properties.meta = 'feature'; | |
| // displays center vertex as a point feature | |
| display(createVertex( | |
| state.line.id, | |
| geojson.geometry.coordinates[state.direction === 'forward' ? geojson.geometry.coordinates.length - 2 : 1], | |
| `${state.direction === 'forward' ? geojson.geometry.coordinates.length - 2 : 1}`, | |
| false, | |
| )); | |
| // displays the line as it is drawn | |
| display(geojson); | |
| const displayMeasurements = getDisplayMeasurements(geojson); | |
| // create custom feature for the current pointer position | |
| const currentVertex = { | |
| type: 'Feature', | |
| properties: { | |
| meta: 'currentPosition', | |
| radiusMetric: displayMeasurements.metric, | |
| radiusStandard: displayMeasurements.standard, | |
| parent: state.line.id, | |
| }, | |
| geometry: { | |
| type: 'Point', | |
| coordinates: geojson.geometry.coordinates[1], | |
| }, | |
| }; | |
| display(currentVertex); | |
| // create custom feature for radius circlemarker | |
| const center = geojson.geometry.coordinates[0]; | |
| const radiusInKm = lineDistance(geojson, 'kilometers'); | |
| const circleFeature = createGeoJSONCircle(center, radiusInKm, state.line.id); | |
| circleFeature.properties.meta = 'radius'; | |
| display(circleFeature); | |
| return null; | |
| }; | |
| export default RadiusMode; |
Using the code that @fc posted on 12 Sep, I also get the "Cannot read property '0' of null" error message. I've found a workaround - in the RadiusMode.onSetup function, just initialise the circle polygon's coordinates with a non-empty array:
geometry: {
type: "Polygon",
coordinates: [[0,0]]
}I guess the real underlying problem is elsewhere, but this at least stops the error message appearing.
@fc do you happen to have a working example of mapbox-gl-circle edition of this? The draw controls is not showing a button for drawing a circle and if I set draw_line_string to CircleMode it still doesn't draw a circle.
ufff,
there is no documentation of all those function toDisplayFeatures, clickAnywhere, state: active.
Notice if you move the circle around it changes to oval (ellipse), depends upon the radius if u draw a large circle around north pole, and then move around it changes to oval
I've published an npm module called mapbox-gl-draw-circle which lets you draw and edit a circle. Please do take a look and feel free to reach out to me if you find any issues.
I've published an npm module called mapbox-gl-draw-circle which lets you draw and edit a circle. Please do take a look and feel free to reach out to me if you find any issues.
Project has been dropped, not updated in 4 years :(
ufff, there is no documentation of all those function
toDisplayFeatures,clickAnywhere,state: active.Notice if you move the circle around it changes to oval (ellipse), depends upon the radius if u draw a large circle around north pole, and then move around it changes to oval
I think "clickAnyWhere" is "onClick"
https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/MODES.md#modeonclick
And I make typescript version.
import MapboxDraw from "@mapbox/mapbox-gl-draw";
import numeral from "numeral";
import lineDistance from "@turf/line-distance";
import { GeoJSON } from "geojson";
const DrawLine = MapboxDraw.modes.draw_line_string;
const RadiusMode = { ...DrawLine };
const createVertex = (
parentId: string,
coordinates: [number, number],
path: string,
selected: boolean
) => {
return {
type: "Feature",
properties: {
meta: "vertex",
parent: parentId,
coord_path: path,
active: selected ? "true" : "false",
},
geometry: {
type: "Point",
coordinates,
},
} as GeoJSON;
};
const createGeoJSONCircle = (
center: [number, number],
radiusInKm: number,
parentId: string,
points: number = 64
) => {
const coords = {
latitude: center[1],
longitude: center[0],
};
const km = radiusInKm;
const ret = [];
const distanceX = km / (111.32 * Math.cos((coords.latitude * Math.PI) / 180));
const distanceY = km / 110.574;
let theta;
let x;
let y;
for (let i = 0; i < points; i += 1) {
theta = (i / points) * (2 * Math.PI);
x = distanceX * Math.cos(theta);
y = distanceY * Math.sin(theta);
ret.push([coords.longitude + x, coords.latitude + y]);
}
ret.push([ret[0]]);
return {
type: "Feature",
geometry: {
type: "Polygon",
coordinates: [ret],
},
properties: {
parent: parentId,
},
};
};
const getDisplayMeasurements = (feature: GeoJSON.Feature) => {
const drawLength = lineDistance(feature) * 1000;
let metricUnits = "m";
let metricFormat = "0,0";
let metricMeasurement;
let standardUnits = "feet";
let standardFormat = "0,0";
let standardMeasurement;
metricMeasurement = drawLength;
if (drawLength >= 1000) {
metricMeasurement = drawLength / 1000;
metricUnits = "km";
metricFormat = "0.00";
}
standardMeasurement = drawLength * 3.28084;
if (standardMeasurement >= 5280) {
standardMeasurement /= 5280;
standardUnits = "mi";
standardFormat = "0.00";
}
const displayMeasurements = {
metric: `${numeral(metricMeasurement).format(metricFormat)} ${metricUnits}`,
standard: `${numeral(standardMeasurement).format(
standardFormat
)} ${standardUnits}`,
};
return displayMeasurements;
};
const doubleClickZoom = {
enable: (ctx: any) => {
setTimeout(() => {
if (
!ctx.map ||
!ctx.map.doubleClickZoom ||
!ctx._ctx ||
!ctx._ctx.store ||
!ctx._ctx.store.getInitialConfigValue
)
return;
if (!ctx._ctx.store.getInitialConfigValue("doubleClickZoom")) return;
ctx.map.doubleClickXoom.enabled();
}, 0);
},
};
RadiusMode.onClick = function (state: any, e: any) {
// this ends the drawing after the user creates a second point, triggering this.onStop
if (state.currentVertexPosition === 1) {
state.line.addCoordinate(0, e.lngLat.lng, e.lngLat.lat);
return this.changeMode("simple_select", { featureIds: [state.line.id] });
}
this.updateUIClasses({ mouse: "add" });
state.line.updateCoordinate(
state.currentVertexPosition,
e.lngLat.lng,
e.lngLat.lat
);
if (state.direction === "forward") {
state.currentVertexPosition += 1; // eslint-disable-line
state.line.updateCoordinate(
state.currentVertexPosition,
e.lngLat.lng,
e.lngLat.lat
);
} else {
state.line.addCoordinate(0, e.lngLat.lng, e.lngLat.lat);
}
return null;
};
// creates the final geojson point feature with a radius property
// triggers draw.create
RadiusMode.onStop = function (state) {
doubleClickZoom.enable(this);
this.activateUIButton();
// check to see if we've deleted this feature
if (this.getFeature(state.line.id) === undefined) return;
// remove last added coordinate
state.line.removeCoordinate("0");
if (state.line.isValid()) {
const lineGeoJson = state.line.toGeoJSON();
// reconfigure the geojson line into a geojson point with a radius property
const pointWithRadius = {
type: "Feature",
geometry: {
type: "Point",
coordinates: lineGeoJson.geometry.coordinates[0],
},
properties: {
radius: (lineDistance(lineGeoJson) * 1000).toFixed(1),
},
};
this.map.fire("draw.create", {
features: [pointWithRadius],
});
} else {
this.deleteFeature(state.line.id, { silent: true });
// this.deleteFeature([state.line.id], { silent: true });
this.changeMode("simple_select", {}, { silent: true });
}
};
RadiusMode.toDisplayFeatures = function (state, geojson: any, display) {
const isActiveLine = geojson.properties.id === state.line.id;
geojson.properties.active = isActiveLine ? "true" : "false";
if (!isActiveLine) return display(geojson);
// Only render the line if it has at least one real coordinate
if (geojson.geometry.coordinates.length < 2) return null;
geojson.properties.meta = "feature";
// displays center vertex as a point feature
display(
createVertex(
state.line.id,
geojson.geometry.coordinates[
state.direction === "forward"
? geojson.geometry.coordinates.length - 2
: 1
],
`${
state.direction === "forward"
? geojson.geometry.coordinates.length - 2
: 1
}`,
false
)
);
// displays the line as it is drawn
display(geojson);
const displayMeasurements = getDisplayMeasurements(geojson);
// create custom feature for the current pointer position
const currentVertex = {
type: "Feature",
properties: {
meta: "currentPosition",
radiusMetric: displayMeasurements.metric,
radiusStandard: displayMeasurements.standard,
parent: state.line.id,
},
geometry: {
type: "Point",
coordinates: geojson.geometry.coordinates[1],
},
} as GeoJSON;
display(currentVertex);
// create custom feature for radius circlemarker
const center = geojson.geometry.coordinates[0];
const radiusInKm = lineDistance(geojson, "kilometers");
const circleFeaturee = createGeoJSONCircle(center, radiusInKm, state.line.id);
const circleFeature = {
...circleFeaturee,
properties: { ...circleFeaturee.properties, meta: "radius" },
} as GeoJSON;
display(circleFeature);
return null;
};
export default RadiusMode;
const modes = {
...MapboxDraw.modes,
draw_radius: RadiusMode,
},
const Draw = new MapboxDraw({
defaultMode: "draw_radius",
displayControlsDefault: false,
userProperties: true,
modes,
});
@gtsl-2 I got errors with your version : it was consuming so much memory that my app was crashing.. any idea why ?
/**
* Custom mapbox-gl-draw mode that modifies draw_line_string.
* - Shows a center point, radius line, and circle polygon while drawing.
* - Forces draw.create on creation of second vertex.
*
* Sourced from: https://github.com/NYCPlanning/labs-factfinder/blob/63bdff401c0a00dd158286d21c165ca885bc02fd/app/utils/radius-mode.js
*/
import MapboxDraw from '@mapbox/mapbox-gl-draw';
import lineDistance from '@turf/line-distance';
import type { Feature, GeoJSON, GeoJsonProperties, LineString, Point, Polygon } from 'geojson';
import type { MapMouseEvent } from 'mapbox-gl';
import numeral from 'numeral';
// --- TYPE DEFINITIONS ---
// The internal state for the RadiusMode.
interface RadiusModeState {
line: MapboxDraw.DrawLineString;
currentVertexPosition: number;
direction: 'forward' | 'backward';
}
// Define the structure for the display measurements object.
interface DisplayMeasurements {
feet: string;
miles: string;
}
// Mapbox Draw's `this` context for custom modes.
type DrawModeContext = MapboxDraw.DrawCustomModeThis;
// --- UTILITY FUNCTIONS ---
/**
* Creates a GeoJSON Point Feature used to represent a vertex.
* @param parentId - The ID of the parent feature.
* @param coordinates - The coordinates of the vertex.
* @param path - The coordinate path of the vertex.
* @param selected - Whether the vertex is selected.
* @returns A GeoJSON Point Feature for the vertex.
*/
function createVertex(parentId: string, coordinates: number[], path: string, selected: boolean): Feature<Point> {
return {
type: 'Feature',
properties: {
meta: 'vertex',
parent: parentId,
coord_path: path,
active: selected ? 'true' : 'false',
},
geometry: {
type: 'Point',
coordinates,
},
};
}
/**
* Creates a circle-like polygon given a center point and a radius.
* @param center - The center coordinates [lng, lat].
* @param radiusInKm - The radius of the circle in kilometers.
* @param parentId - The ID of the parent feature.
* @param points - The number of points to use to approximate the circle.
* @returns A GeoJSON Polygon Feature representing the circle.
*/
function createGeoJSONCircle(center: number[], radiusInKm: number, parentId: string, points = 64): Feature<Polygon> {
const coords = {
latitude: center[1],
longitude: center[0],
};
const km = radiusInKm;
const ret: number[][] = [];
const distanceX = km / (111.32 * Math.cos((coords.latitude * Math.PI) / 180));
const distanceY = km / 110.574;
let theta: number;
let x: number;
let y: number;
for (let i = 0; i < points; i += 1) {
theta = (i / points) * (2 * Math.PI);
x = distanceX * Math.cos(theta);
y = distanceY * Math.sin(theta);
ret.push([coords.longitude + x, coords.latitude + y]);
}
ret.push(ret[0]);
return {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [ret],
},
properties: {
parent: parentId,
},
};
}
/**
* Creates metric and standard display strings for the line's length.
* @param lineFeature - The GeoJSON LineString feature to measure.
* @returns An object with formatted length strings.
*/
function getDisplayMeasurements(lineFeature: Feature<LineString>): DisplayMeasurements {
const drawnLength = lineDistance(lineFeature) * 1000; // in meters
const feetMeasurement = drawnLength * 3.28084;
const milesMeasurement = feetMeasurement / 5280;
return {
feet: `${numeral(feetMeasurement).format('0,0')} ft`,
miles: `${numeral(milesMeasurement).format('0.00')} mi`,
};
}
// Helper to re-enable double-click zoom.
const doubleClickZoom = {
enable: (ctx: DrawModeContext) => {
setTimeout(() => {
if (
!ctx.map ||
!ctx.map.doubleClickZoom
)
return;
if (!ctx.map.doubleClickZoom) return;
ctx.map.doubleClickZoom.enable();
}, 0);
},
};
// --- CUSTOM MODE DEFINITION ---
const RadiusMode: any = { ...MapboxDraw.modes.draw_line_string };
RadiusMode.clickAnywhere = function (this: DrawModeContext, state: RadiusModeState, e: MapMouseEvent) {
// End drawing after the user creates a second point.
if (state.currentVertexPosition === 1) {
state.line.addCoordinate(0, e.lngLat.lng, e.lngLat.lat);
return this.changeMode('simple_select', { featureIds: [state.line.id] });
}
this.updateUIClasses({ mouse: 'add' });
state.line.updateCoordinate(state.currentVertexPosition.toString(), e.lngLat.lng, e.lngLat.lat);
if (state.direction === 'forward') {
state.currentVertexPosition += 1;
state.line.updateCoordinate(state.currentVertexPosition.toString(), e.lngLat.lng, e.lngLat.lat);
} else {
state.line.addCoordinate(0, e.lngLat.lng, e.lngLat.lat);
}
return null;
};
RadiusMode.onStop = function (this: DrawModeContext, state: RadiusModeState) {
doubleClickZoom.enable(this);
this.activateUIButton();
if (this.getFeature(state.line.id as string) === undefined) return;
// Clean up the feature by removing the temporary second point.
state.line.removeCoordinate('0');
if (state.line.isValid()) {
const lineGeoJson = state.line.toGeoJSON() as GeoJSON.Feature<LineString, GeoJsonProperties>;
// Reconfigure the GeoJSON line into a GeoJSON point with a radius property.
const pointWithRadius: Feature<Point> = {
type: 'Feature',
geometry: {
type: 'Point',
// The first coordinate is the center.
coordinates: lineGeoJson.geometry.coordinates[0],
},
properties: {
radius: (lineDistance(lineGeoJson) * 1000).toFixed(1), // radius in meters
},
};
this.map.fire('draw.create', {
features: [pointWithRadius],
});
} else {
this.deleteFeature(state.line.id as any, { silent: true });
this.changeMode('simple_select', {}, { silent: true });
}
};
RadiusMode.toDisplayFeatures = function (
this: DrawModeContext,
state: RadiusModeState,
geojson: Feature,
display: (geojson: GeoJSON) => void,
) {
const isActiveLine = geojson.properties?.id === state.line.id;
if (geojson.properties) {
geojson.properties.active = isActiveLine ? 'true' : 'false';
}
if (!isActiveLine) return display(geojson);
// Do not render if there are not enough coordinates
if (!geojson.geometry || geojson.geometry.type !== 'LineString' || geojson.geometry.coordinates.length < 2) return null;
if (geojson.properties) {
geojson.properties.meta = 'feature';
}
// Display the center vertex
const centerCoordIndex = state.direction === 'forward' ? geojson.geometry.coordinates.length - 2 : 1;
display(
createVertex(
state.line.id as string,
geojson.geometry.coordinates[centerCoordIndex],
`${centerCoordIndex}`,
false,
),
);
// Display the radius line as it is being drawn
display(geojson);
// Get and display measurements
const displayMeasurements = getDisplayMeasurements(geojson as Feature<LineString>);
// Create a custom feature for the current pointer position with measurement info
const currentVertex: Feature<Point> = {
type: 'Feature',
properties: {
meta: 'currentPosition',
radiusFeet: displayMeasurements.feet,
radiusMiles: displayMeasurements.miles,
parent: state.line.id,
},
geometry: {
type: 'Point',
// The second coordinate is the live pointer position.
coordinates: geojson.geometry.coordinates[1],
},
};
display(currentVertex);
// Create and display the circle polygon
const center = geojson.geometry.coordinates[0];
const radiusInKm = lineDistance(geojson as Feature<LineString>, 'kilometers');
const circleFeature = createGeoJSONCircle(center, radiusInKm, state.line.id as string);
if (circleFeature.properties) {
circleFeature.properties.meta = 'radius';
}
display(circleFeature);
return null;
};
export default RadiusMode;Here's an updated version (few any because the state.line.id isn't at the right type) but it works well.
Some alternatives for those stopping by to read this.
If you really and truly need circle support, you may want to consider using Leaflet.draw instead:
https://github.com/Leaflet/Leaflet.draw
It's possible that it could be combined with mapbox-gl-leaflet so you can keep mapbox-gl under the hood (untested).
https://github.com/mapbox/mapbox-gl-leaflet/issues
Or, I believe you could use mapbox.js with Leaflet.draw too (also, untested):
https://www.mapbox.com/mapbox.js/api/v3.1.1/
Now I haven't tried the above (yet) but you'll have to weigh the pros/cons to see if this is an approach that could work for you and your project.