Leaflet.DistortableImage

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.
Advantages include:
- It can handle over 100 images smoothly, even on a smartphone
- Images can be right-clicked and downloaded individually in their original state
- CSS3 transforms are GPU-accelerated in most (all?) browsers, for a very smooth UI
- No need to server-side generate raster GeoTiffs, tilesets, etc. in order to view distorted imagery layers
- Images use DOM event handling for real-time distortion
- Full resolution download option for large images, using WebGL acceleration
Download as zip or clone the repo to get a local copy.
Also available on NPM as leaflet-distortableimage:
npm i leaflet-distortableimage
Compatibility with Leaflet versions
Compatible with Leaflet 1.0.0 and greater
Setup
- From the root directory, run
npm install
and
npm install leaflet --no-save
Demo
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.
Single Image Interface
The simplest implementation is to create a map with our recommended TileLayer
, then create an L.distortableImageOverlay
instance and add it onto the map.
map = L.map('map').setView([51.505, -0.09], 13);
map.addGoogleMutant();
map.whenReady(function() {
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
actions
(optional, default: [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.RevertAction
:
img = L.distortableImageOverlay('example.jpg', {
actions: [L.OpacityAction, L.DeleteAction, L.RevertAction],
}).addTo(map);
Corners
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);
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}]"
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}]"
Editable
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 individual L.DistortableImageOverlay
instances and should instead be passed to the collection instance.
Full-resolution download
fullResolutionSrc
(optional)
We've added a GPU-accelerated means to generate a full resolution version of the distorted image; it requires two additional dependencies to enable; see how we've included them in the demo:
<script src="../node_modules/webgl-distort/dist/webgl-distort.js"></script>
<script src="../node_modules/glfx/glfx.js"></script>
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);
Mode
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:
- distort (default): Distortion via individually draggable corners.
- rotate: Rotation only.
- scale: Resize only.
- freeRotate: Combines the rotate and scale modes into one.
- lock: Locks the image in place. Disables any user gestures, toolbar actions, or hotkeys that are not associated with mode. Exception:
L.ExportAction
will still be enabled.
In the below example, the image will be initialiazed with "freeRotate" handles:
img = L.distortableImageOverlay('example.jpg', {
mode: 'freeRotate',
}).addTo(map);
Selected
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.
Suppress 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)
Multiple Image Interface
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:
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)
],
});
imgGroup = L.distortableCollection().addTo(map);
imgGroup.addLayer(img);
imgGroup.addLayer(img2);
Note: You must instantiate a blank collection, then dynamically add layers to it like above. This is because DistortableCollection
internally uses the layeradd
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
actions
(optional, default: [L.ExportAction
, L.DeleteAction
, L.LockAction
, L.UnlocksAction
], 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
editable
(optional, default: true, value: boolean)
See editable.
✤ Suppress Toolbar
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.
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)
],
});
img2 = L.distortableImageOverlay('example.jpg', {
suppressToolbar: true,
});
imgGroup = L.distortableCollection({
supressToolbar: true,
}).addTo(map);
UI and functionalities
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:
- Collect an indvidiual image with shift +
click
. - Or for touch devices,
touch
+ hold
(aka longpress
). - Collect multiple images at once with shift +
drag
(Uses our L.Map.BoxCollector
).
decollect:
- In order to return to the single-image interface, where each
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... - ...Click on the map or hit the esc key to quickly decollect all.
Toolbar Actions (& Keybindings)
Single Image Interface
Defaults:
- L.ScaleAction (s):
- L.RotateAction (r):
- L.FreeRotateAction (f)
- L.LockAction (l, u)
- Toggles between
lock
mode and the initially set default mode (distort
by default).
- L.BorderAction (b)
- Toggles a thin border around the overlay.
- L.OpacityAction (o)
- L.ExportAction (e)
- L.DeleteAction (backscpace, delete)
- Permanently deletes the image from the map. Uses a
confirm()
modal dialog. - windows
backspace
/ mac delete
Addons:
- L.StackAction (q, a)
- Switch an image's overlap compared to neighboring images back and forth into view. Employs
bringToFront()
and bringToBack()
from the Leaflet API.
- L.GeolocateAction (WIP)
- L.RevertAction (WIP)
- Restores the image to its original proportions and scale, but keeps its current rotation angle and location on the map intact.
Multiple Image Interface
Defaults:
- L.ExportAction (e) (WIP)
- L.DeleteAction (backscpace, delete)
- Permanently deletes a collection of images from the map.
- L.LockAction (l)
- Sets
lock
mode for a collection of images.
- L.UnlocksAction (u)
- Unsets
lock
mode for a collection of images.
Quick API Reference
L.Map
We have extended Leaflet's L.Map
to include a convenience method for this library:
addGoogleMutant(opts? <Mutant options>): this
- Adds a Google Mutant layer with location labels according to our recommended setup.
- Label visibility is toggled by double clicking on the map.
- Mutant options: {
- labels: <boolean>, default: true
- If set to
false
, the mutant layer will not have location labels
- labelOpacity: <number 0, 1>, default: 0
- If set to
1
, labels will be initially visible
- mutantOpacity: <number 0..1>, default: 0.8
- Same as Leaflet's
L.TileLayer
opacity
option
- maxZoom: <number 0..21>, default: 18
- Same as Leaflet's
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. - The mutant layer will appear blurry for zoom levels exceeding 18.
- minZoom: <number 0..maxZoom>, default: 0
- Same as Leaflet's
L.TileLayer
maxZoom
option
}
And the following custom handlers:
doubleClickLabels: this
- When location labels are added via
#addGoogleMutant
, this handler is enabled by default to allow toggling their visibility by double clicking on the map. - Afterwards, can be enabled / disabled during runtime via Leaflet's Handler API.
- Overrides the map's default
doubleClickZoom
handler when enabled. When disabled, automatically re-enables it.
boxCollector: this
- Overrides the map's default
boxZoom
handler. - Allows multiple images to be collected when shift +
drag
ing on the map for the multiple image interface.
We have made slight changes to a default Leaflet handler:
doubleClickZoom: this
- This handler and
doubleClickLabels
time and fire a custom singleclick
event on map click. It is fired after a 3ms timeout if the click doesn't become a doubleclick. - This allows our images to remain selected during associated double click events on the map (in this case zooming).
L.DistortableImageOverlay
An individual image instance that can have transformation methods called on it and can be "selected".
getCorner(idx <number 0..3>): LatLng
- Returns the coordinates of the image corner at index.
getCorners(): 4 [LatLng, LatLng, LatLng, LatLng]
- Returns the coordinates of the image corners in NW, NE, SW, SE order.
setCorner(idx <number 0..3>, LatLng): this
- Updates the coordinates of the image corner at index to LatLng and, where applicable, marker and toolbar positioning.
- We use this internally for
distort
mode.
setCorners(LatLngCorners): this
setCornersFromPoints(PointCorners): this
- Same as
#setCorners
, but takes in a "corners" object made up of Point
s instead of LatLng
s. - PointCorners: { keys: <number 0..4>, values: Point }
getCenter(): LatLng
- Returns the center (centroid) of the image.
scaleBy(factor <number>): this
- Scales the image by the given factor and calls
#setCorners
. - A scale of 0 or 1 will leave the image unchanged - but 0 causes the function to automatically return.
- A negative scale will invert the image and, depending on the factor, change its size.
- Ex.
img.scaleBy(0.5)
rotateBy(rad <number>): this
- Rotates the image by the given radian angle and calls
#setCorners
.
isSelected(): Boolean
- Returns true if the individual image instance is selected.
select(): this
- Selects an individual image instance.
- If its editing handler is disabled or the multiple image interface is on (
imgGroup.anyCollected()
returns true), instead just returns false. - Internally invoked on image
click
.
deselect(): this
- Deselects an individual image instance.
- If its editing handler is disabled or it is not currently selected, instead just returns false.
- Internally invoked on map
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 the enable
/ disable
runtime API and using the editable
option during initialization is in runtime, neither individual image instaces nor the collection group get precedence over the other.
enable(): this
- Sets up the editing interface (makes the image interactive).
- Called internally by default (editable), but unlike the option it can be used in runtime and is not ignored if there is a collection group. In fact...
- ...An individual image can be enabled while the group is disabled. i.e. calling
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
- Deselects the image, and disables its editing interface (makes it non-interactive).
- Called internally by default on image deletion.
- An individual image can be disabled while the group is enabled.
enabled(): Boolean
- Returns true if editing on the individual image instance is enabled.
img.editing.enabled()
getMode(): String
- Returns the current
mode
of the image if it's selected, otherwise returns false.
nextMode(): this
- Sets the mode of the image to the next one in the
modes
array by passing it to #setMode.
- If the image is not selected or
modes
only has 1 mode, it will instead return false. - We use this internally to iterate through an image's editing modes easily on
dblclick
, but you can call it programmatically if you find a need. Note that dblclick
also selects the image (given it's not disabled or collected)
setMode(mode <string>): this
- Sets the
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 is selcted. Otherwise returns false.
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
- Returns true if the passed
L.DistortableImageOverlay
instance is collected, i.e. its underlying HTMLImageElement
has a class containing "selected".
anyCollected(): Boolean
- Returns true if any
L.DistortableImageOverlay
instances are collected.
L.DistortableCollection.Edit
Same as L.DistortableImage.Edit
but for the collection (L.DistortableCollection
) instance.
enable(): this
- Sets up the multi-editing interface.
- Called internally by default, see editable.
- Calls each individual image's
#enable
method and then enables the multi-image interface.
disable(): this
- Removes the editing interface (makes the image non-interactive, removes markers and toolbar).
- Called internally by default on image group deletion, but can also be used for custom behavior.
- Calls each individual image's
#disable
method and disables the multi-image interface.
enabled(): Boolean
- Returns true if editing on the collection instance is enabled.
imgGroup.editing.enabled()
removeTool(action <EditAction>)
- Removes the passed tool from the control toolbar in runtime.
- ex:
imgGroup.removeTool(Deletes)
addTool(action <EditAction>)
- Adds the passed tool to the end of the control toolbar in runtime. Returns false if the tool is not available or is already present.
hasTool(action <EditAction>): Boolean
- Returns true if the tool is present in the currently rendered control toolbar.
Additional Components
Keymapper
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.
Contributing
There are plenty of outstanding issues to resolve. Please consider helping out!
- This project uses
grunt
to do a lot of things, including concatenate source files from /src/
to /DistortableImageOverlay.js
:
$ npm install
$ npm install leaflet --no-save
$ grunt
- To build all files from
/src/
into the /dist/
folder, run:
$ grunt concat:dist
- Optional: We use SVG for our icon system. Please visit our wiki SVG Icon System if you are interested in making updates to them or just simply learning about our workflow.
Contributors
Many more at https://github.com/publiclab/Leaflet.DistortableImage/graphs/contributors