A Leaflet extension to distort images -- "rubbersheeting" -- for the MapKnitter.org (src) image georectification service by Public Lab. Leaflet.DistortableImage allows for perspectival distortions of images, client-side, using CSS3 transformations in the DOM.
Begin running (and contributing to) this codebase immediately with GitPod:
Advantages include:
Download as zip or clone the repo to get a local copy.
Also available on NPM as leaflet-distortableimage:
npm i leaflet-distortableimage
Compatible with Leaflet 1.0.0 and greater
Read more about MapKnitter going offline here: https://publiclab.org/mapknitter
Check out a prototype of the Mapknitter Lite project which enables you to:
Check out this simple demo.
And watch this GIF demo:
To test the code, open index.html
in your browser and click and drag the markers on the edges of the image. The image will show perspectival distortions.
For the additional features in the multiple image interface, open select.html
and use shift + click on an image or shift + drag on the map to "multi-select" (collect) images. For touch screens, touch + hold the image.
The simplest implementation is to create a map with our recommended TileLayer
, then create an L.distortableImageOverlay
instance and add it onto the map.
// set the initial map center and zoom level
map = L.map('map').setView([51.505, -0.09], 13);
// adds a Google Satellite layer with a toner label overlay
map.addGoogleMutant();
map.whenReady(function() {
// By default, 'img' will be placed centered on the map view specified above
img = L.distortableImageOverlay('example.jpg').addTo(map);
});
Note: map.addGoogleMutant()
is a convenience function for adding our recommended layer to the map. If you want a different baselayer, skip this line and add your preferred setup instead.
Options available to pass during L.DistortableImageOverlay
initialization:
actions
(optional, default: [L.DragAction
, L.ScaleAction
, L.DistortAction
, L.RotateAction
, L.FreeRotateAction
, L.LockAction
, L.OpacityAction
, L.BorderAction
, L.ExportAction
, L.DeleteAction
], value: array)
If you would like to overrwrite the default toolbar actions available for an individual image's L.Popup
toolbar, pass an array with the actions you want. Reference the available values here.
For example, to overrwrite the toolbar to only include L.OpacityAction
and L.DeleteAction
, and also add on an additional non-default like L.RestoreAction
:
img = L.distortableImageOverlay('example.jpg', {
actions: [L.OpacityAction, L.DeleteAction, L.RestoreAction],
}).addTo(map);
corners
(optional, default: an array of LatLang
s that position the image on the center of the map, value: array)Allows you to set an image's position on the map manually (somewhere other than the center default).
Note that this can manipulate the shape and dimensions of your image.
The corners should be passed as an array of L.latLng
objects in NW, NE, SW, SE order (in a "Z" shape).
They will be stored on the image. See the Quick API Reference for their getter and setter methods.
Example:
img = L.distortableImageOverlay('example.jpg', {
corners: [
L.latLng(51.52,-0.14),
L.latLng(51.52,-0.10),
L.latLng(51.50,-0.14),
L.latLng(51.50,-0.10),
],
}).addTo(map);
// you can grab the initial corner positions
JSON.stringify(img.getCorners())
=> "[{"lat":51.52,"lng":-0.14},{"lat":51.52,"lng":-0.1},{"lat":51.5,"lng":-0.14},{"lat":51.5,"lng":-0.1}]"
// ...move the image around...
// you can check the new corner positions.
JSON.stringify(img.getCorners())
=> "[{"lat":51.50685099607552,"lng":-0.06058305501937867},{"lat":51.50685099607552,"lng":-0.02058595418930054},{"lat":51.486652692081925,"lng":-0.06058305501937867},{"lat":51.486652692081925,"lng":-0.02058595418930054}]"
// note there is an added level of precision after dragging the image
editable
(optional, default: true, value: boolean)
Internally, we use the image load
event to trigger a call to img.editing.enable()
, which sets up the editing interface (makes the image interactive, adds markers and toolbar).
If you want to enable editing based on custom logic instead, you can pass editable: false
and then write your own function with a call to img.editing.enable()
. Other passed options such as selected: true
and mode
will still be applicable and applied then.
Note: when using the multiple image interface (L.DistortableCollection
) this option will be ignored on individualL.DistortableImageOverlay
instances and should instead be passed to the collection instance.
fullResolutionSrc
(optional)
We've added a GPU-accelerated means to generate a full resolution version of the distorted image.
When instantiating a Distortable Image, pass in a fullResolutionSrc
option set to the url of the higher resolution image. This image will be used in full-res exporting.
img = L.distortableImageOverlay('example.jpg', {
fullResolutionSrc: 'large.jpg',
}).addTo(map);
Our project includes two additional dependencies to enable this feature, glfx.js and webgl-distort, both of which you can find in our package.json.
mode
(optional, default: "distort", value: string)
This option sets the image's initial editing mode, meaning the corresponding editing handles will always appear first when you interact with the image.
Values available to pass to mode
are:
L.ExportAction
will still be enabled.In the below example, the image will be initialized with "freeRotate" handles:
img = L.distortableImageOverlay('example.jpg', {
mode: 'freeRotate',
}).addTo(map);
If you select a mode
that is removed or unavailable, your image will just be assigned the first available mode
on initialization.
Limiting modes:
Eachmode
is just a special type of action, so to ensure that these are always in sync themodes
available on an image instance can be limited by theactions
available on it. To remove a mode, limit its corresponding action via theactions
option during initialization. This holds true even whensuppressToolbar: true
is passed.
In the below example, the image will be initialiazed with 'freeRotate'
handles, and limit its available modes to 'freeRotate'
and 'scale'
.
img = L.distortableImageOverlay('example.jpg', {
mode: 'freeRotate',
actions: [L.FreeRotateAction, L.ScaleAction, L.BorderAction, L.OpacityAction],
}).addTo(map);
Likewise, it is possible to remove or add actions
during runtime (addTool
, removeTool
), and if those actions are modes it will remove / add the mode
.
rotation
(optional, default: {deg: 0, rad: 0}, value: hash)
Set the initial rotation angle of your image, in degrees or radians. Set the unit as the key, and the angle as the value.
img = L.distortableImageOverlay('example.jpg', {
rotation: {
deg: 180,
},
}).addTo(map);
selected
(optional, default: false, value: boolean)
By default, your image will initially appear on the screen as unselected (no toolbar or markers). Interacting with it will make them visible.
If you prefer that an image initially appears as selected instead, pass selected: true
.
Note: when working with the multi-image interface, only the last overlay you pass selected: true
to will appear with editing handles and a toolbar.
suppressToolbar
(optional, default: false, value: boolean)
To initialize an image without its L.Popup
instance toolbar, pass it suppressToolbar: true
.
Typically, editing actions are triggered through our toolbar interface. If disabling the toolbar, the developer will need to implement their own toolbar UI connected to our actions (WIP API for doing this)
Our DistortableCollection
class builds on the single image interface to allow working with multiple images simultaneously.
The setup is relatively similar.
Although not required, you will probably want to pass corners
to individual images when adding multiple or they will be positioned on top of eachother.
Here is an example with two images:
// 1. Instantiate map
// 2. Instantiate images but this time *dont* add them directly to the map
img = L.distortableImageOverlay('example.jpg', {
corners: [
L.latLng(51.52, -0.14),
L.latLng(51.52,-0.10),
L.latLng(51.50, -0.14),
L.latLng(51.50,-0.10),
],
});
img2 = L.distortableImageOverlay('example.jpg', {
corners: [
L.latLng(51.51, -0.20),
L.latLng(51.51,-0.16),
L.latLng(51.49, -0.21),
L.latLng(51.49,-0.17),
],
});
// 3. Instantiate an empty `DistortableCollection` group
imgGroup = L.distortableCollection().addTo(map);
// 4. Add the images to the group
imgGroup.addLayer(img);
imgGroup.addLayer(img2);
Note: You must instantiate a blank collection, then dynamically add layers to it like above. This is becauseDistortableCollection
internally uses thelayeradd
event to enable additional editing features on images as they are added, and it is only triggered when they are added dynamically.
Options available to pass during L.DistortableCollection
initialization:
actions
(optional, default: [L.ExportAction
, L.DeleteAction
, L.LockAction
, L.UnlockAction
], value: array)Overrwrite the default toolbar actions for an image collection's L.Control
toolbar. Reference the available values here.
For example, to overrwrite the toolbar to only include the L.DeleteAction
:
imgGroup = L.distortableCollection({
actions: [L.DeleteAction],
}).addTo(map);
To add / remove a tool from the toolbar at runtime, we have also added the methods addTool(action)
and removeTool(action)
.
editable
(optional, default: true, value: boolean)
See editable.
suppressToolbar
(optional, default: false, value: boolean)
Same usage as suppressToolbar, but for the collection group's L.Control
toolbar instance.
This provides the developer with the flexibility to keep the popup toolbars, the control toolbar, both, or neither.
For ex.
// suppress this images personal toolbar
img = L.distortableImageOverlay('example.jpg', {
suppressToolbar: true,
corners: [
L.latLng(51.52, -0.14),
L.latLng(51.52,-0.10),
L.latLng(51.50, -0.14),
L.latLng(51.50,-0.10),
],
});
// suppress the other images personal toolbar
img2 = L.distortableImageOverlay('example.jpg', {
suppressToolbar: true,
});
// suppress collection toolbar accessed during multi-image selection
imgGroup = L.distortableCollection({
suppressToolbar: true,
}).addTo(map);
tooltipText
(optional, default: '', value: string)
This provides the flexibility to add tooltip text to every image placed on the tile layer.
For ex.
// Sets up tooltip text for an image, the text is displayed when mouse is placed on it
img = L.distortableImageOverlay(
'example.jpg',
{tooltipText: 'Sample text'}
);
Currently it supports multiple image selection and translations, and WIP we are working on porting all editing tools to work for it, such as opacity, etc. Image distortions (via modes) still use the single-image interface.
A single toolbar instance (using L.control
) renders the set of tools available to use on collections of images.
collect:
click
.touch
+ hold
(aka longpress
).drag
(Uses our L.Map.BoxCollector
).decollect:
L.popup
toolbar only applies actions on the image it's attached to, you must toggle all images out of collection with shift
+ click / touch
+ hold
, or...confirm()
modal dialog.backspace
/ mac delete
distort
mode.drag
mode.freeRotate
mode.lock
mode and the initially set default mode (distort
by default).rotate
mode.scale
mode.These may be added using addTool()
, like this:
distortableImageLayer.editing.addTool(L.StackAction);
bringToFront()
and bringToBack()
from the Leaflet API.Defaults:
lock
mode for a collection of images.lock
mode for a collection of images.L.Map
We have extended Leaflet's L.Map
to include a convenience method for this library:
addGoogleMutant(opts? <Mutant options>): this
Mutant options: {[mutantOpacity][, maxZoom][, minZoom][, labels][, labelOpacity][, doubleClickLabels]}
mutantOpacity
(default 0.8, value: number 0..1)L.TileLayer
opacity
option.maxZoom
(default: 18, value: number 0..21)L.TileLayer
maxZoom
option, except has a maximum value of 21 because higher zoom levels on the mutant layer will result in an error being thrown.minZoom
(default: 0, value: number 0..maxZoom)L.TileLayer
minZoom
option.labels
(default: true, value: boolean)false
, the mutant layer will not have location labels.labelOpacity
(default: 1, value: number 0, 1)0
, labels will be initially invisible.undefined
if labels: false
is also passed.doubleClickLabels
(default: true, value: boolean)dblclick
. To turn this functionality off, set this option to false.undefined
if labels: false
is also passed.map.mutantOptions.
And the following custom handlers:
doubleClickLabels: this
dblclick
.#addGoogleMutant
unless the options labels: false
or doubleClickLabels: false
are passed to it.labels: false
passed, removed from map altogether.doubleClickLabels: false
was passed, just disabled and can always be enabled during runtime via Leaflet's Handler API.doubleClickZoom
handler when enabled.boxCollector: this
boxZoom
handler. To use boxZoom
instead, pass the options { boxCollector: false, boxZoom: true }
to the map on initialization.
drag
ing on the map for the multiple image interface.
We have slightly changed a default Leaflet handler:
doubleClickZoom: this
enabled
(and will return false) while the doubleClickLabels
handler is enabled
.doubleClickLabels
time and fire a custom singleclick
event on map click.Our "doubleClick" handlers mentioned above use a customsingleclick
event to run logic on mapdblclick
while allowing the images on the map to remainselected
. You can read more about the implications of this and how to disable it on our wiki "singleclick event".
L.DistortableImageOverlay
An individual image instance that can have transformation methods called on it and can be "selected".
getCorner(idx <number 0..3>): LatLng
getCorners(): 4 [LatLng, LatLng, LatLng, LatLng]
setCorner(idx <number 0..3>, LatLng): this
distort
mode.setCorners(LatLngCorners): this
#setCorner
, but takes in a "corners" object made up of LatLng
s to update all 4 corners with only one UI update at the end.var scaledCorners = {}; var i; var p; for (i = 0; i < 4; i++) { p = map .project(img.getCorner(i)) .subtract(center) .multiplyBy(scale) .add(center); scaledCorners[i] = map.unproject(p); } img.setCorners(scaledCorners);
setCornersFromPoints(PointCorners): this
#setCorners
, but takes in a "corners" object made up of Point
s instead of LatLng
s.getCenter(): LatLng
getAngle([unit = 'deg'] <string>): Number
units
, or in degrees by default.
Number
will always be >= 0.unit
(optional, default: 'deg', value: string 'deg'|'rad') img.getAngle(); img.getAngle('deg'); img.getAngle('rad');
setAngle(angle <number>, [unit = 'deg'] <string>): this
angle
in units
, or in degrees by default.unit
(optional, default: 'deg', value: string 'deg'|'rad') img.setAngle(180); img.setAngle(180, 'deg'); img.setAngle(Math.PI, 'rad');
rotateBy(angle <number>, [unit = 'deg'] <string>): this
angle
in units
, or in degrees by default.unit
(optional, default: 'deg', value: string 'deg'|'rad') img.rotateBy(180); img.rotateBy(180, 'deg'); img.rotateBy(Math.PI, 'rad');
scaleBy(factor <number>): this
#setCorners
.img.scaleBy(0.5)
restore(): this
isSelected(): Boolean
select(): this
imgGroup.anyCollected() === true
), does not select and instead just returns undefined.click
.deselect(): this
click
and image collect (shift + click
).L.DistortableImageOverlay.Edit
A handler that holds the keybindings and toolbar API for an image instance. It is always initialized with an instance of L.DistortableImageOverlay
. Besides code organization, it provides the ability to enable
and disable
image editing using the Leaflet API.
Note: The main difference between theenable
/disable
runtime API and using theeditable
option during initialization is in runtime, neither individual image instaces nor the collection group get precedence over the other.
enable(): this
img.editing.enable()
after imgGroup.editing.disable()
is valid. In this case, the single image interface will be available on this image but not the multi-image interface.disable(): this
enabled(): Boolean
img.editing.enabled()
hasMode(mode <string>): Boolean
getMode(): String
mode
of the image.getModes(): Hash
nextMode(): this
mode
of the image to the next one in the modes
array by passing it to #setMode
.modes
only has 1 mode
, it will instead return undefined and not update the image's mode
.dblclick
, but you can call it programmatically if you find a need. Note that dblclick
also selects the image (given it's not disabled and the collection interface is not on).setMode(mode <string>): this
mode
of the image to the passed one given that it is in the modes
array, it is not already the current mode
, and the image editing interface is enabled. Otherwise, does not set the mode and instead just returns undefined.L.DistortableCollection
A collection instance made up of a group of images. Images can be "collected" in this interface and a "collected" image is never also "selected".
isCollected(img <DistortableImageOverlay>): Boolean
L.DistortableImageOverlay
instance is collected, i.e. its underlying HTMLImageElement
has a class containing "selected".anyCollected(): Boolean
L.DistortableImageOverlay
instances are collected.Retrieve image from Json file containing image property set. The property set can be used to instantiate new imageOverlays.
recreateImagesFromJsonUrl(string): {avg_cm_per_pixel , imgCollectionProps<{}>}
imgGroup = L.distortableCollection().addTo(map);
// 2. Get property set for each of the imagesconst imageCollectionObj = await map.imgGroup.recreateImagesFromJsonUrl(jsonDownloadURL);
Note: jsonDownloadUrl
must be in either of these formats:image = L.distortableImageOverlay(imageURL,{tooltipText, corners});
map.imgGroup.addLayer(image);
L.DistortableCollection.Edit
Same as L.DistortableImage.Edit
but for the collection (L.DistortableCollection
) instance.
enable(): this
#enable
method and then enables the multi-image interface.disable(): this
#disable
method and disables the multi-image interface.enabled(): Boolean
imgGroup.editing.enabled()
removeTool(action <EditAction>): this
imgGroup.removeTool(Deletes)
addTool(action <EditAction>): this
replaceTool(old <EditAction>), next <EditAction>)
hasTool(action <EditAction>): Boolean
// add a position option with combinations of 'top', 'bottom', 'left' or 'right'
L.distortableImage.keymapper(map, {
position: 'topleft',
});
Options:
position
(optional, default: 'topright', value: string)Adds a control onto the map which opens a keymapper legend showing the available key bindings for different editing / interaction options.
(WIP) Currently includes keybindings for all available actions and does not update yet if you use the actions
API to limit available actions.
You can translate the LDI toolbar buttons in your native language by providing a custom translation
object to DistortableImageOverlay
or DistortableCollection
.
NOTE: If you don't specify a custom translation for a certain field, it will fallback to English.
These are the defaults:
var translation = {
deleteImage: 'Delete Image',
deleteImages: 'Delete Images',
distortImage: 'Distort Image',
dragImage: 'Drag Image',
exportImage: 'Export Image',
exportImages: 'Export Images',
removeBorder: 'Remove Border',
addBorder: 'Add Border',
freeRotateImage: 'Free rotate Image',
geolocateImage: 'Geolocate Image',
lockMode: 'Lock Mode',
lockImages: 'Lock Images',
makeImageOpaque: 'Make Image Opaque',
makeImageTransparent: 'Make Image Transparent',
restoreImage: 'Restore Natural Image',
rotateImage: 'Rotate Image',
scaleImage: 'Scale Image',
stackToFront: 'Stack to Front',
stackToBack: 'Stack to Back',
unlockImages: 'Unlock Images',
confirmImageDelete: 'Are you sure? This image will be permanently deleted from the map.',
confirmImagesDeletes: 'Are you sure? These images will be permanently deleted from the map.',
};
For confirmImagesDeletes
you can pass a function that returns a string.
This is useful for languages where noun form depends on the number:
var translation = {
confirmImagesDeletes: function(n) {
var cond = n%10 >= 2 && n%10 <= 4 && n%100 - n%10 !== 10;
var str = 'Czy na pewno chcesz usunąć ' + n;
if(cond) str += ' obrazy?';
else str += ' obrazów?';
return str;
},
// ...
}
L.DistortableImageOverlay
img = L.distortableImageOverlay('example.jpg', {
translation: {
deleteImage: 'Obriši sliku',
distortImage: 'Izobliči sliku',
dragImage: 'Pomjeri sliku',
// ...
},
}).addTo(map);
L.DistortableCollection
imgGroup = L.distortableCollection({
translation: {
deleteImages: 'Obriši slike',
exportImages: 'Izvezi slike',
// ...
},
}).addTo(map);
See CONTRIBUTING.md for details on how you can contribute to Leaflet.DistortableImage.
Many more at https://github.com/publiclab/Leaflet.DistortableImage/graphs/contributors