Extending marker.js 2 with custom markers
marker.js 2 is extendable and you can create your own custom markers to add shapes and/or functionality to your annotation setup.
High-level overview
All marker.js 2 markers extend MarkerBase
class. That's where you would start if you wanted to create something really unique that doesn't borrow anything from other built-in markers.
Normally, that's not the case though. You would likely want to benefit from the resizing, movement, rotation, and other features that built-in markers have. So, for most real-life scenarios a starting point would be one of the following classes:
RectangularBoxMarkerBase
provides the rectangular control box that handles moving, rotation, and resizing for a marker. Most markers in marker.js 2 are descendants of this class. Some just add the specific shape to be drawn (like RectangleMarker (FrameMarker, CoverMarker), EllipseMarker, etc.), others are more complex and add quite a lot of extra functionality (TextMarker, FreehandMarker). These more advanced markers still benefit of all the features fromRectangularBoxMarkerBase
for marker manipulation.LinearMarkerBase
andLineMarker
. If you just want a marker with 2 control grips/points you start withLinearMarkerBase
but if your marker is a line with some extra features then you may want to start withLineMarker
.ArrowMarker
andMeasurementMarker
extendLineMarker
and add ornaments to it.TextMarker
handles all of text input and sizing. So,CalloutMarker
extends it and gets all the text handling for free. Then it just adds the callout shape to it.
Rather than going deeper into "theory" let's build a custom marker and explain the concepts along the way.
Building a TriangleMarker: Walkthrough
Let's build something that is fairly simple and realistic but not too useful (otherwise we'd have to include it in marker.js itself). TriangleMarker
seems like a good candidate.
For simplicity, we will not build a fully-flexible triangle marker but rather a isosceles triangle that is controlled by a rectangular control box. As you may have guessed we will base our marker on RectangularBoxMarkerBase
and get it from there.
We will start this walkthrough from the point we left off in our React walkthrough. But don't worry, there will be no React code here. This is just in case you want to follow along in a realistic project with immediate visual feedback.
TL;DR
You can get the complete code for this walkthrough here. The "meat" of this demo is in TriangleMarker.ts.
TypeScript vs. JavaScript
We are using TypeScript in this walkthrough but you can find a JavaScript version here
The basics
We are basing our TriangleMarker
class on RectangularBoxMarkerBase
and we need to set up its type name (used when preserving state and to add markers via they string name), title (for accessibility), and icon (for the toolbar button). For the icon we will just copy a triangle icon from Material Design Icons.
TriangleMarker.ts
at this point looks like this:import { RectangularBoxMarkerBase } from 'markerjs2';
export class TriangleMarker extends RectangularBoxMarkerBase {
/**
* String type name of the marker type.
*/
public static typeName = 'TriangleMarker';
/**
* Marker type title (display name) used for accessibility and other attributes.
*/
public static title = 'Triangle marker';
/**
* SVG icon markup displayed on toolbar buttons.
*/
public static icon = `<svg viewBox="0 0 24 24"><path d="M12,2L1,21H23M12,6L19.53,19H4.47" /></svg>`;
}
And if we add it to our available markers we will get a triangle button in the toolbar. It won't do anything useful yet, though.
Create a triangle
In order to create an actual triangle let's go through the next steps.
First, let's define fields to hold the color and line width of our triangle and initialize them in the constructor:
/**
* Border color.
*/
protected strokeColor = 'transparent';
protected strokeWidth = 0;
/**
* Creates a new marker.
*
* @param container - SVG container to hold marker's visual.
* @param overlayContainer - overlay HTML container to hold additional overlay elements while editing.
* @param settings - settings object containing default markers settings.
*/
constructor(container: SVGGElement, overlayContainer: HTMLDivElement, settings: Settings) {
super(container, overlayContainer, settings);
this.strokeColor = settings.defaultColor;
this.strokeWidth = settings.defaultStrokeWidth;
}
Next, let's calculate the triangle points and add a method to create its visual. Plus a method to update our triangle's points that we'll call on resize. Our triangle will have its tips in the bottom left and right corners, and in the top center.
private getPoints(): string {
return `0,${this.height
} ${this.width / 2},0 ${this.width},${this.height}`;
}
/**
* Creates marker visual.
*/
protected createVisual(): void {
this.visual = SvgHelper.createPolygon(this.getPoints(), [
['stroke', this.strokeColor],
['fill', 'transparent'],
['stroke-width', this.strokeWidth.toString()]
]);
this.addMarkerVisualToContainer(this.visual);
}
/**
* Sets marker's visual after manipulation.
*/
protected setPoints(): void {
super.setSize();
SvgHelper.setAttributes(this.visual, [
['points', this.getPoints()]
]);
}
SvgHelper
is a little utility class included with marker.js 2 to simplify creation and manipulation of SVG objects.
Next, let's add methods to react to manipulation events to create and resize our triangle when needed.
/**
* Handles pointer (mouse, touch, stylus, etc.) down event.
*
* @param point - event coordinates.
* @param target - direct event target element.
*/
public pointerDown(point: IPoint, target?: EventTarget): void {
super.pointerDown(point, target);
if (this.state === 'new') {
this.createVisual();
this.moveVisual(point);
this._state = 'creating';
}
}
/**
* Resize marker based on current pointer coordinates and context.
* @param point
*/
protected resize(point: IPoint): void {
super.resize(point);
this.setPoints();
}
/**
* Handles pointer (mouse, touch, stylus, etc.) up event.
*
* @param point - event coordinates.
*/
public pointerUp(point: IPoint): void {
super.pointerUp(point);
this.setPoints();
}
And finally (for this section), we need to override the ownsTarget()
method to return true if pointer event happened over our visual.
/**
* Returns true if passed SVG element belongs to the marker. False otherwise.
*
* @param el - target element.
*/
public ownsTarget(el: EventTarget): boolean {
if (super.ownsTarget(el) || el === this.visual) {
return true;
} else {
return false;
}
}
At this point, your triangle marker has all of its core functionality and you should be able to place, move, resize, and rotate it. Here's the complete code that made this possible:
import { IPoint, RectangularBoxMarkerBase, Settings, SvgHelper } from 'markerjs2';
export class TriangleMarker extends RectangularBoxMarkerBase {
/**
* String type name of the marker type.
*/
public static typeName = 'TriangleMarker';
/**
* Marker type title (display name) used for accessibility and other attributes.
*/
public static title = 'Triangle marker';
/**
* SVG icon markup displayed on toolbar buttons.
*/
public static icon = `<svg viewBox="0 0 24 24"><path d="M12,2L1,21H23M12,6L19.53,19H4.47" /></svg>`;
/**
* Border color.
*/
protected strokeColor = 'transparent';
protected strokeWidth = 0;
/**
* Creates a new marker.
*
* @param container - SVG container to hold marker's visual.
* @param overlayContainer - overlay HTML container to hold additional overlay elements while editing.
* @param settings - settings object containing default markers settings.
*/
constructor(container: SVGGElement, overlayContainer: HTMLDivElement, settings: Settings) {
super(container, overlayContainer, settings);
this.strokeColor = settings.defaultColor;
this.strokeWidth = settings.defaultStrokeWidth;
this.createVisual = this.createVisual.bind(this);
}
private getPoints(): string {
return `0,${this.height
} ${this.width / 2},0 ${this.width},${this.height}`;
}
/**
* Creates marker visual.
*/
protected createVisual(): void {
this.visual = SvgHelper.createPolygon(this.getPoints(), [
['stroke', this.strokeColor],
['fill', 'transparent'],
['stroke-width', this.strokeWidth.toString()]
]);
this.addMarkerVisualToContainer(this.visual);
}
/**
* Sets marker's visual after manipulation.
*/
protected setPoints(): void {
super.setSize();
SvgHelper.setAttributes(this.visual, [
['points', this.getPoints()]
]);
}
/**
* Handles pointer (mouse, touch, stylus, etc.) down event.
*
* @param point - event coordinates.
* @param target - direct event target element.
*/
public pointerDown(point: IPoint, target?: EventTarget): void {
super.pointerDown(point, target);
if (this.state === 'new') {
this.createVisual();
this.moveVisual(point);
this._state = 'creating';
}
}
/**
* Resize marker based on current pointer coordinates and context.
* @param point
*/
protected resize(point: IPoint): void {
super.resize(point);
this.setPoints();
}
/**
* Handles pointer (mouse, touch, stylus, etc.) up event.
*
* @param point - event coordinates.
*/
public pointerUp(point: IPoint): void {
super.pointerUp(point);
this.setPoints();
}
/**
* Returns true if passed SVG element belongs to the marker. False otherwise.
*
* @param el - target element.
*/
public ownsTarget(el: EventTarget): boolean {
if (super.ownsTarget(el) || el === this.visual) {
return true;
} else {
return false;
}
}
}
Using toolbox to customize triangle color
At this point, our triangle is created with the default border color and there's no way for the user to change it. Let's address this.
marker.js 2 has a toolbox and a set of included toolbox panels that you can use in your marker to modify its parameters. You can create your own toolbox panels as well, but we will not go into that here.
In our demo we will set up a color picker panel to customize the triangle border color.
Let's import the panel, set it up and add a color changed event handler for it:
protected strokePanel: ColorPickerPanel;
/**
* Creates a new marker.
*
* @param container - SVG container to hold marker's visual.
* @param overlayContainer - overlay HTML container to hold additional overlay elements while editing.
* @param settings - settings object containing default markers settings.
*/
constructor(container: SVGGElement, overlayContainer: HTMLDivElement, settings: Settings) {
super(container, overlayContainer, settings);
this.strokeColor = settings.defaultColor;
this.strokeWidth = settings.defaultStrokeWidth;
this.createVisual = this.createVisual.bind(this);
this.setStrokeColor = this.setStrokeColor.bind(this);
this.strokePanel = new ColorPickerPanel(
'Line color',
settings.defaultColorSet,
settings.defaultColor
);
this.strokePanel.onColorChanged = this.setStrokeColor;
}
/**
* Sets marker's line color.
* @param color - new line color.
*/
protected setStrokeColor(color: string): void {
this.strokeColor = color;
if (this.visual) {
SvgHelper.setAttributes(this.visual, [['stroke', this.strokeColor]]);
}
}
Now, we just need to override toolboxPanels
property to return our toolbox panel:
/**
* Returns the list of toolbox panels for this marker type.
*/
public get toolboxPanels(): ToolboxPanel[] {
return [this.strokePanel];
}
With these changes the user should be able to create and modify the triangles as well as change their border color.
Handling state
You may not need to save and restore the state of your marker.js implementation. In this case, feel free to skip this section. But if you are building a "proper" marker.js 2 addon you have to handle the state correctly.
First, let's create an interface to describe our state. The only extra besides what's included in RectangularBoxMarkerBaseState
is our strokeColor
.
/**
* Represents TriangleMarker's state.
*/
export interface TriangleMarkerState extends RectangularBoxMarkerBaseState {
/**
* Triangle border stroke (line) color.
*/
strokeColor: string;
}
Next we need to override methods to save and restore state:
/**
* Returns current marker state that can be restored in the future.
*/
public getState(): TriangleMarkerState {
const result: TriangleMarkerState = Object.assign({
strokeColor: this.strokeColor
}, super.getState());
result.typeName = TriangleMarker.typeName;
return result;
}
/**
* Restores previously saved marker state.
*
* @param state - previously saved state.
*/
public restoreState(state: MarkerBaseState): void {
const rectState = state as TriangleMarkerState;
this.strokeColor = rectState.strokeColor;
this.createVisual();
super.restoreState(state);
this.setPoints();
}
Scaling
The final step is to make our marker respond to scaling of the whole marker area. We just need to recalculate and reset our triangle points.
/**
* Scales marker. Used after the image resize.
*
* @param scaleX - horizontal scale
* @param scaleY - vertical scale
*/
public scale(scaleX: number, scaleY: number): void {
super.scale(scaleX, scaleY);
this.setPoints();
}
And that's it.
Complete code for the TriangleMarker
import {
ColorPickerPanel,
IPoint,
MarkerBaseState,
RectangularBoxMarkerBase,
RectangularBoxMarkerBaseState,
Settings,
SvgHelper,
ToolboxPanel,
} from "markerjs2";
/**
* Represents TriangleMarker's state.
*/
export interface TriangleMarkerState extends RectangularBoxMarkerBaseState {
/**
* Triangle border stroke (line) color.
*/
strokeColor: string;
}
export class TriangleMarker extends RectangularBoxMarkerBase {
/**
* String type name of the marker type.
*/
public static typeName = "TriangleMarker";
/**
* Marker type title (display name) used for accessibility and other attributes.
*/
public static title = "Triangle marker";
/**
* SVG icon markup displayed on toolbar buttons.
*/
public static icon = `<svg viewBox="0 0 24 24"><path d="M12,2L1,21H23M12,6L19.53,19H4.47" /></svg>`;
/**
* Border color.
*/
protected strokeColor = "transparent";
protected strokeWidth = 0;
protected strokePanel: ColorPickerPanel;
/**
* Creates a new marker.
*
* @param container - SVG container to hold marker's visual.
* @param overlayContainer - overlay HTML container to hold additional overlay elements while editing.
* @param settings - settings object containing default markers settings.
*/
constructor(
container: SVGGElement,
overlayContainer: HTMLDivElement,
settings: Settings
) {
super(container, overlayContainer, settings);
this.strokeColor = settings.defaultColor;
this.strokeWidth = settings.defaultStrokeWidth;
this.createVisual = this.createVisual.bind(this);
this.setStrokeColor = this.setStrokeColor.bind(this);
this.strokePanel = new ColorPickerPanel(
"Line color",
settings.defaultColorSet,
settings.defaultColor
);
this.strokePanel.onColorChanged = this.setStrokeColor;
}
private getPoints(): string {
return `0,${this.height} ${this.width / 2},0 ${this.width},${this.height}`;
}
/**
* Creates marker visual.
*/
protected createVisual(): void {
this.visual = SvgHelper.createPolygon(this.getPoints(), [
["stroke", this.strokeColor],
["fill", "transparent"],
["stroke-width", this.strokeWidth.toString()],
]);
this.addMarkerVisualToContainer(this.visual);
}
/**
* Sets marker's visual after manipulation.
*/
protected setPoints(): void {
super.setSize();
SvgHelper.setAttributes(this.visual, [["points", this.getPoints()]]);
}
/**
* Handles pointer (mouse, touch, stylus, etc.) down event.
*
* @param point - event coordinates.
* @param target - direct event target element.
*/
public pointerDown(point: IPoint, target?: EventTarget): void {
super.pointerDown(point, target);
if (this.state === "new") {
this.createVisual();
this.moveVisual(point);
this._state = "creating";
}
}
/**
* Resize marker based on current pointer coordinates and context.
* @param point
*/
protected resize(point: IPoint): void {
super.resize(point);
this.setPoints();
}
/**
* Handles pointer (mouse, touch, stylus, etc.) up event.
*
* @param point - event coordinates.
*/
public pointerUp(point: IPoint): void {
super.pointerUp(point);
this.setPoints();
}
/**
* Returns true if passed SVG element belongs to the marker. False otherwise.
*
* @param el - target element.
*/
public ownsTarget(el: EventTarget): boolean {
if (super.ownsTarget(el) || el === this.visual) {
return true;
} else {
return false;
}
}
/**
* Sets marker's line color.
* @param color - new line color.
*/
protected setStrokeColor(color: string): void {
this.strokeColor = color;
if (this.visual) {
SvgHelper.setAttributes(this.visual, [["stroke", this.strokeColor]]);
}
}
/**
* Returns the list of toolbox panels for this marker type.
*/
public get toolboxPanels(): ToolboxPanel[] {
return [this.strokePanel];
}
/**
* Returns current marker state that can be restored in the future.
*/
public getState(): TriangleMarkerState {
const result: TriangleMarkerState = Object.assign(
{
strokeColor: this.strokeColor,
},
super.getState()
);
result.typeName = TriangleMarker.typeName;
return result;
}
/**
* Restores previously saved marker state.
*
* @param state - previously saved state.
*/
public restoreState(state: MarkerBaseState): void {
const rectState = state as TriangleMarkerState;
this.strokeColor = rectState.strokeColor;
this.createVisual();
super.restoreState(state);
this.setPoints();
}
/**
* Scales marker. Used after the image resize.
*
* @param scaleX - horizontal scale
* @param scaleY - vertical scale
*/
public scale(scaleX: number, scaleY: number): void {
super.scale(scaleX, scaleY);
this.setPoints();
}
}
Live version
Here's TriangleMarker in action in CodeSandbox:
See also
- The complete demo for this walkthrough.
- JavaScript version of the demo.